Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions __tests__/hooks/custom-hooks-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ 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(["/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);
Expand Down
45 changes: 25 additions & 20 deletions src/hooks/custom-hooks-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomHook[]> {
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();
}

Expand Down Expand Up @@ -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<LoadAllResult> {
clearCustomHooks();
Expand All @@ -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}`);
}
}
}

Expand Down
41 changes: 26 additions & 15 deletions src/hooks/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down Expand Up @@ -140,7 +140,7 @@ async function installHooksImpl(
cwd?: string,
includeBeta = false,
source?: string,
customPoliciesPath?: string,
customPoliciesPath?: string | string[],
removeCustomHooks = false,
cli?: IntegrationType[],
replace = false,
Expand Down Expand Up @@ -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<ReturnType<typeof loadCustomHooks>> = [];
try {
Expand All @@ -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", {
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -678,17 +684,22 @@ export async function listHooks(cwd?: string): Promise<void> {

// 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 ?? ""}`);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/policy-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export interface HooksConfig {
enabledPolicies: string[];
llm?: LlmConfig;
policyParams?: Record<string, Record<string, unknown>>;
customPoliciesPath?: string;
customPoliciesPath?: string | string[];
/**
* Turn off convention-discovered custom policies (`.failproofai/policies/`)
* without deleting or renaming the files. Absent means enabled — the default
Expand Down