From 62862e64f8264131814710fe489c39bdaf06a28e Mon Sep 17 00:00:00 2001 From: anantvardhanpandey Date: Wed, 29 Jul 2026 13:09:51 +0530 Subject: [PATCH 1/2] feat(hooks): support multiple custom policy file paths in customPoliciesPath (#60) --- __tests__/hooks/custom-hooks-loader.test.ts | 14 +++++++ src/hooks/custom-hooks-loader.ts | 45 ++++++++++++--------- src/hooks/manager.ts | 41 ++++++++++++------- src/hooks/policy-types.ts | 2 +- 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/__tests__/hooks/custom-hooks-loader.test.ts b/__tests__/hooks/custom-hooks-loader.test.ts index 23d8c5b4..a462170a 100644 --- a/__tests__/hooks/custom-hooks-loader.test.ts +++ b/__tests__/hooks/custom-hooks-loader.test.ts @@ -41,6 +41,20 @@ describe("hooks/custom-hooks-loader", () => { expect(result).toEqual([]); }); + it("loads multiple custom policy files when customPoliciesPath is an array", async () => { + const { existsSync } = await import("node:fs"); + vi.mocked(existsSync).mockReturnValue(true); + + const { rewriteFileTree } = await import("../../src/hooks/loader-utils"); + vi.mocked(rewriteFileTree).mockResolvedValue([ + { src: "/path/one.js", dst: "/path/one.__failproofai_tmp__.mjs" }, + ]); + + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + await loadCustomHooks(["/path/one.js", "/path/two.js"]); + expect(rewriteFileTree).toHaveBeenCalledTimes(2); + }); + it("logs warning and returns [] when file does not exist", async () => { const { existsSync } = await import("node:fs"); vi.mocked(existsSync).mockReturnValue(false); diff --git a/src/hooks/custom-hooks-loader.ts b/src/hooks/custom-hooks-loader.ts index 7c2023a0..f530744f 100644 --- a/src/hooks/custom-hooks-loader.ts +++ b/src/hooks/custom-hooks-loader.ts @@ -124,23 +124,26 @@ async function loadSingleFile( * Clears the registry, loads the file, returns registered hooks. */ export async function loadCustomHooks( - customPoliciesPath: string | undefined, + customPoliciesPath: string | string[] | undefined, opts?: { strict?: boolean; sessionCwd?: string }, ): Promise { if (!customPoliciesPath) return []; + const paths = Array.isArray(customPoliciesPath) ? customPoliciesPath : [customPoliciesPath]; + if (paths.length === 0) return []; - const absPath = isAbsolute(customPoliciesPath) - ? customPoliciesPath - : resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath); + clearCustomHooks(); + const root = opts?.sessionCwd ?? process.cwd(); - if (!existsSync(absPath)) { - if (opts?.strict) throw new Error(`Custom hooks file not found: ${absPath}`); - hookLogWarn(`customPoliciesPath not found: ${absPath}`); - return []; + for (const path of paths) { + if (!path) continue; + const absPath = isAbsolute(path) ? path : resolve(root, path); + if (!existsSync(absPath)) { + if (opts?.strict) throw new Error(`Custom hooks file not found: ${absPath}`); + hookLogWarn(`customPoliciesPath not found: ${absPath}`); + continue; + } + await loadSingleFile(absPath, opts); } - - clearCustomHooks(); - await loadSingleFile(absPath, opts); return getCustomHooks(); } @@ -185,7 +188,7 @@ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void { } export async function loadAllCustomHooks( - customPoliciesPath: string | undefined, + customPoliciesPath: string | string[] | undefined, opts?: { sessionCwd?: string; customPoliciesEnabled?: boolean }, ): Promise { clearCustomHooks(); @@ -201,15 +204,17 @@ export async function loadAllCustomHooks( // purpose, so switching off *discovery* shouldn't silently drop it too. const conventionEnabled = opts?.customPoliciesEnabled !== false; - // 1. Explicit customPoliciesPath (existing behavior) + // 1. Explicit customPoliciesPath (supports single string or array of strings) if (customPoliciesPath) { - const absPath = isAbsolute(customPoliciesPath) - ? customPoliciesPath - : resolve(projectRoot, customPoliciesPath); - if (existsSync(absPath)) { - await loadSingleFile(absPath); - } else { - hookLogWarn(`customPoliciesPath not found: ${absPath}`); + const paths = Array.isArray(customPoliciesPath) ? customPoliciesPath : [customPoliciesPath]; + for (const path of paths) { + if (!path) continue; + const absPath = isAbsolute(path) ? path : resolve(projectRoot, path); + if (existsSync(absPath)) { + await loadSingleFile(absPath); + } else { + hookLogWarn(`customPoliciesPath not found: ${absPath}`); + } } } diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 3cc5d1e8..f933986b 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -109,7 +109,7 @@ export async function installHooks( cwd?: string, includeBeta = false, source?: string, - customPoliciesPath?: string, + customPoliciesPath?: string | string[], removeCustomHooks = false, cli?: IntegrationType[], options: InstallHooksOptions = {}, @@ -140,7 +140,7 @@ async function installHooksImpl( cwd?: string, includeBeta = false, source?: string, - customPoliciesPath?: string, + customPoliciesPath?: string | string[], removeCustomHooks = false, cli?: IntegrationType[], replace = false, @@ -216,7 +216,9 @@ async function installHooksImpl( if (removeCustomHooks) { delete configToWrite.customPoliciesPath; } else if (customPoliciesPath) { - configToWrite.customPoliciesPath = resolve(customPoliciesPath); + configToWrite.customPoliciesPath = Array.isArray(customPoliciesPath) + ? customPoliciesPath.map((p) => resolve(p)) + : resolve(customPoliciesPath); // Validate the file before committing it to config let validatedHooks: Awaited> = []; try { @@ -232,6 +234,7 @@ async function installHooksImpl( console.error(`Error: ${msg}`); process.exit(1); } + const pathStr = Array.isArray(customPoliciesPath) ? customPoliciesPath.join(", ") : customPoliciesPath; if (validatedHooks.length === 0) { try { await trackHookEvent(getInstanceId(), "custom_policy_validation_failed", { @@ -240,7 +243,7 @@ async function installHooksImpl( }); } catch {} console.error( - `Error: no hooks registered in ${customPoliciesPath}. ` + + `Error: no hooks registered in ${pathStr}. ` + `Make sure your file calls customPolicies.add(...) at least once.`, ); process.exit(1); @@ -254,7 +257,10 @@ async function installHooksImpl( if (removeCustomHooks) { console.log("Custom hooks path cleared."); } else if (configToWrite.customPoliciesPath) { - console.log(`Custom hooks path: ${configToWrite.customPoliciesPath}`); + const displayPath = Array.isArray(configToWrite.customPoliciesPath) + ? configToWrite.customPoliciesPath.join(", ") + : configToWrite.customPoliciesPath; + console.log(`Custom hooks path: ${displayPath}`); } // Write hooks for each selected CLI @@ -678,17 +684,22 @@ export async function listHooks(cwd?: string): Promise { // Custom Policies section if (config.customPoliciesPath) { - console.log(`\n \u2500\u2500 Custom Policies (${config.customPoliciesPath}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`); - if (!existsSync(config.customPoliciesPath)) { - console.log(` \x1B[31m\u2717 File not found: ${config.customPoliciesPath}\x1B[0m`); - } else { - const hooks = await loadCustomHooks(config.customPoliciesPath); - if (hooks.length === 0) { - console.log(` \x1B[31m\u2717 ERR failed to load (check ~/.failproofai/logs/hooks.log)\x1B[0m`); + const customPaths = Array.isArray(config.customPoliciesPath) + ? config.customPoliciesPath + : [config.customPoliciesPath]; + for (const customPath of customPaths) { + console.log(`\n \u2500\u2500 Custom Policies (${customPath}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`); + if (!existsSync(customPath)) { + console.log(` \x1B[31m\u2717 File not found: ${customPath}\x1B[0m`); } else { - const descColWidth = nameColWidth; - for (const hook of hooks) { - console.log(` \x1B[32m\u2713\x1B[0m ${hook.name.padEnd(descColWidth)}${hook.description ?? ""}`); + const hooks = await loadCustomHooks(customPath); + if (hooks.length === 0) { + console.log(` \x1B[31m\u2717 ERR failed to load (check ~/.failproofai/logs/hooks.log)\x1B[0m`); + } else { + const descColWidth = nameColWidth; + for (const hook of hooks) { + console.log(` \x1B[32m\u2713\x1B[0m ${hook.name.padEnd(descColWidth)}${hook.description ?? ""}`); + } } } } diff --git a/src/hooks/policy-types.ts b/src/hooks/policy-types.ts index 47692c98..2487ce97 100644 --- a/src/hooks/policy-types.ts +++ b/src/hooks/policy-types.ts @@ -84,7 +84,7 @@ export interface HooksConfig { enabledPolicies: string[]; llm?: LlmConfig; policyParams?: Record>; - customPoliciesPath?: string; + customPoliciesPath?: string | string[]; /** * Turn off convention-discovered custom policies (`.failproofai/policies/`) * without deleting or renaming the files. Absent means enabled — the default From c86992d169c2817d4cc8f51adee110269856a5e7 Mon Sep 17 00:00:00 2001 From: anantvardhanpandey Date: Wed, 29 Jul 2026 13:19:39 +0530 Subject: [PATCH 2/2] test(hooks): fix rewriteFileTree mock return type in custom-hooks-loader test (#60) --- __tests__/hooks/custom-hooks-loader.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/__tests__/hooks/custom-hooks-loader.test.ts b/__tests__/hooks/custom-hooks-loader.test.ts index a462170a..21d5c8d0 100644 --- a/__tests__/hooks/custom-hooks-loader.test.ts +++ b/__tests__/hooks/custom-hooks-loader.test.ts @@ -46,9 +46,7 @@ describe("hooks/custom-hooks-loader", () => { vi.mocked(existsSync).mockReturnValue(true); const { rewriteFileTree } = await import("../../src/hooks/loader-utils"); - vi.mocked(rewriteFileTree).mockResolvedValue([ - { src: "/path/one.js", dst: "/path/one.__failproofai_tmp__.mjs" }, - ]); + vi.mocked(rewriteFileTree).mockResolvedValue(["/path/one.__failproofai_tmp__.mjs"]); const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); await loadCustomHooks(["/path/one.js", "/path/two.js"]);