Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.0.15-beta.1 — 2026-07-29

### Features
- Support multiple explicit custom policy files. Repeat `--custom <path>` during installation to configure an ordered list stored as `customPoliciesPaths`; runtime loading, CLI output, uninstall, and the dashboard now handle every file. Existing `customPoliciesPath` configurations remain supported. (#623)
- Add independent dashboard toggles for policies in every explicit and convention policy file. Off policies are excluded from runtime registration before event matching, while source-qualified IDs keep same-named policies in different files independently controllable. (#623)

## 0.0.15-beta.0 — 2026-07-23

### Dependencies
Expand Down
2 changes: 2 additions & 0 deletions __tests__/actions/get-hooks-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ describe("getHooksConfigAction — convention policies", () => {
name: "enforce-formal-review",
description: "Require a formal review",
eventScope: "PreToolUse, Stop",
id: "convention:project:enforce-formal-review-policies.mjs:enforce-formal-review",
enabled: true,
},
]);
} finally {
Expand Down
38 changes: 38 additions & 0 deletions __tests__/actions/update-hooks-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";

const { config, writeHooksConfig } = vi.hoisted(() => ({
config: { enabledPolicies: [] as string[], disabledCustomPolicies: [] as string[] },
writeHooksConfig: vi.fn(),
}));

vi.mock("@/src/hooks/hooks-config", () => ({ readHooksConfig: () => config, writeHooksConfig }));
vi.mock("@/src/hooks/hook-telemetry", () => ({ trackHookEvent: vi.fn() }));
vi.mock("@/lib/telemetry-id", () => ({ getInstanceId: () => "test" }));

import { toggleCustomPolicyAction } from "@/app/actions/update-hooks-config";

describe("toggleCustomPolicyAction", () => {
beforeEach(() => {
config.disabledCustomPolicies = [];
writeHooksConfig.mockClear();
});

it("stores a source-qualified ID when disabling a custom policy", async () => {
await toggleCustomPolicyAction("custom:/tmp/team.js:deny-all", false);
expect(writeHooksConfig).toHaveBeenCalledWith({
enabledPolicies: [],
disabledCustomPolicies: ["custom:/tmp/team.js:deny-all"],
});
});

it("removes the field when the final disabled policy is enabled", async () => {
config.disabledCustomPolicies = ["convention:project:team-policies.mjs:deny-all"];
await toggleCustomPolicyAction("convention:project:team-policies.mjs:deny-all", true);
expect(writeHooksConfig).toHaveBeenCalledWith({ enabledPolicies: [] });
});

it("rejects IDs outside custom policy namespaces", async () => {
await expect(toggleCustomPolicyAction("block-sudo", false)).rejects.toThrow("Invalid custom policy ID");
});
});
117 changes: 117 additions & 0 deletions __tests__/e2e/hooks/custom-hooks.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,85 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
// ── Core mechanics ────────────────────────────────────────────────────────────

describe("custom-hooks core mechanics", () => {
it("can disable one policy in an explicit custom policy file", () => {
const env = createFixtureEnv();
const hookPath = env.writeHook("mixed.mjs", `
import { customPolicies, allow, deny } from "failproofai";
customPolicies.add({ name: "disabled-deny", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked") });
customPolicies.add({ name: "still-enabled", match: { events: ["PreToolUse"] }, fn: async () => allow() });
`);
env.writeConfig({
enabledPolicies: [], customPoliciesPaths: [hookPath],
disabledCustomPolicies: [`custom:${hookPath}:disabled-deny`],
});
assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

it("toggles duplicate policy names independently across explicit files", () => {
const env = createFixtureEnv();
const firstPath = env.writeHook("first.mjs", `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "shared-name", match: { events: ["PreToolUse"] }, fn: async () => deny("first") });
`);
const secondPath = env.writeHook("second.mjs", `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "shared-name", match: { events: ["PreToolUse"] }, fn: async () => deny("second") });
`);
env.writeConfig({
enabledPolicies: [], customPoliciesPaths: [firstPath, secondPath],
disabledCustomPolicies: [`custom:${firstPath}:shared-name`],
});
assertPreToolUseDeny(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));

env.writeConfig({
enabledPolicies: [], customPoliciesPaths: [firstPath, secondPath],
disabledCustomPolicies: [`custom:${firstPath}:shared-name`, `custom:${secondPath}:shared-name`],
});
assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

it("honours a disabled custom policy ID inherited from another config scope", () => {
const env = createFixtureEnv();
const hookPath = env.writeHook("scoped.mjs", `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "scoped-deny", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked") });
`);
env.writeConfig({ enabledPolicies: [], customPoliciesPaths: [hookPath] }, "project");
env.writeConfig({
enabledPolicies: [], disabledCustomPolicies: [`custom:${hookPath}:scoped-deny`],
}, "global");
assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

it("ignores stale disabled IDs without affecting active policies", () => {
const env = createFixtureEnv();
const hookPath = env.writeHook("active.mjs", `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "active-deny", match: { events: ["PreToolUse"] }, fn: async () => deny("active") });
`);
env.writeConfig({
enabledPolicies: [], customPoliciesPath: hookPath,
disabledCustomPolicies: ["custom:/deleted/file.mjs:old-policy"],
});
assertPreToolUseDeny(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

it("loads multiple explicit custom policy files", () => {
const env = createFixtureEnv();
const firstPath = env.writeHook("first.mjs", `
import { customPolicies, allow } from "failproofai";
customPolicies.add({ name: "first", match: { events: ["PreToolUse"] }, fn: async () => allow() });
`);
const secondPath = env.writeHook("second.mjs", `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "second", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked by second file") });
`);
env.writeConfig({ enabledPolicies: [], customPoliciesPaths: [firstPath, secondPath] });
const result = runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home });
assertPreToolUseDeny(result);
expect(result.stdout).toContain("blocked by second file");
});

it("custom hook that calls deny() → deny decision", () => {
const env = createFixtureEnv();
const hookPath = env.writeHook("deny-all.mjs", `
Expand Down Expand Up @@ -379,6 +458,44 @@ describe("custom-hooks — customPoliciesPath scope levels", () => {
// ── Convention-based policies (.failproofai/policies/) ──────────────────────

describe("convention-based policies (.failproofai/policies/)", () => {
it("can disable one convention policy without disabling its file", () => {
const env = createFixtureEnv();
env.writeConfig({
enabledPolicies: [],
disabledCustomPolicies: ["convention:project:mixed-policies.mjs:disabled-deny"],
});
env.writePolicyFile("mixed-policies.mjs", `
import { customPolicies, allow, deny } from "failproofai";
customPolicies.add({ name: "disabled-deny", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked") });
customPolicies.add({ name: "still-enabled", match: { events: ["PreToolUse"] }, fn: async () => allow() });
`);
assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

it("toggles same-named project and user convention policies independently", () => {
const env = createFixtureEnv();
const source = `
import { customPolicies, deny } from "failproofai";
customPolicies.add({ name: "shared-convention", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked") });
`;
env.writePolicyFile("shared-policies.mjs", source, "project");
env.writePolicyFile("shared-policies.mjs", source, "global");
env.writeConfig({
enabledPolicies: [],
disabledCustomPolicies: ["convention:project:shared-policies.mjs:shared-convention"],
});
assertPreToolUseDeny(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));

env.writeConfig({
enabledPolicies: [],
disabledCustomPolicies: [
"convention:project:shared-policies.mjs:shared-convention",
"convention:user:shared-policies.mjs:shared-convention",
],
});
assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }));
});

// ── Basic discovery and execution ─────────────────────────────────────────

it("project convention policy fires: deny hook in .failproofai/policies/", () => {
Expand Down
42 changes: 40 additions & 2 deletions __tests__/hooks/hooks-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe("hooks/hooks-config", () => {
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
const config = readMergedHooksConfig(CWD);
expect(config.customPoliciesPath).toBe("/local/hooks.js");
expect(config.customPoliciesPaths).toEqual(["/local/hooks.js"]);
});

it("customPoliciesPath: project scope wins over local", async () => {
Expand All @@ -182,7 +182,45 @@ describe("hooks/hooks-config", () => {
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
const config = readMergedHooksConfig(CWD);
expect(config.customPoliciesPath).toBe("/project/hooks.js");
expect(config.customPoliciesPaths).toEqual(["/project/hooks.js"]);
});

it("normalizes a legacy customPoliciesPath to customPoliciesPaths", async () => {
mockFiles({
[globalPath]: { enabledPolicies: [], customPoliciesPath: "/global/hooks.js" },
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
const config = readMergedHooksConfig(CWD);
expect(config.customPoliciesPaths).toEqual(["/global/hooks.js"]);
});

it("customPoliciesPaths: preserves multiple paths from the highest defining scope", async () => {
mockFiles({
[projectPath]: { enabledPolicies: [], customPoliciesPaths: ["/project/a.js", "/project/b.js"] },
[globalPath]: { enabledPolicies: [], customPoliciesPaths: ["/global/hooks.js"] },
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
const config = readMergedHooksConfig(CWD);
expect(config.customPoliciesPaths).toEqual(["/project/a.js", "/project/b.js"]);
});

it("disabledCustomPolicies: unions and deduplicates IDs across scopes", async () => {
mockFiles({
[projectPath]: { enabledPolicies: [], disabledCustomPolicies: ["custom:/a.js:rule"] },
[localPath]: { enabledPolicies: [], disabledCustomPolicies: ["custom:/b.js:rule"] },
[globalPath]: { enabledPolicies: [], disabledCustomPolicies: ["custom:/a.js:rule"] },
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
expect(readMergedHooksConfig(CWD).disabledCustomPolicies).toEqual([
"custom:/a.js:rule",
"custom:/b.js:rule",
]);
});

it("disabledCustomPolicies: omits an empty merged set", async () => {
mockFiles({ [globalPath]: { enabledPolicies: [], disabledCustomPolicies: [] } });
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
expect(readMergedHooksConfig(CWD).disabledCustomPolicies).toBeUndefined();
});

it("returns no policyParams key when no params configured", async () => {
Expand Down
11 changes: 11 additions & 0 deletions __tests__/hooks/loader-path-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ describe("loadAllCustomHooks deduplicates customPoliciesPath against convention
expect(result.hooks.filter((h) => h.name === "dedup-probe")).toHaveLength(1);
});

it("normalizes absolute explicit paths before deduplicating convention files", async () => {
const file = join(project, ".failproofai", "policies", "shared-policies.mjs");
writeFileSync(file, SRC, "utf8");
const aliased = join(project, ".failproofai", "policies", "..", "policies", "shared-policies.mjs");

const result = await loadAllCustomHooks(aliased, { sessionCwd: project });

expect(result.hooks.filter((h) => h.name === "dedup-probe")).toHaveLength(1);
expect(result.conventionSources).toHaveLength(0);
});

it("does not report it as a convention source once step 1 has loaded it", async () => {
const file = join(project, ".failproofai", "policies", "shared-policies.mjs");
writeFileSync(file, SRC, "utf8");
Expand Down
Loading
Loading