diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac1e427..4ec587f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.0.15-beta.1 — 2026-07-29 + +### Features +- Support multiple explicit custom policy files. Repeat `--custom ` 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) + +### Fixes +- Show dashboard-disabled custom and convention policies as `OFF` in `failproofai policies` instead of rendering a green checkmark. Convention files containing both enabled and disabled policies are marked `MIXED`, with each disabled policy identified in the summary. (#623) +- Render the event-detail disclosure glyph on the Policies dashboard instead of displaying the literal Unicode escape text `\u25BE`. (#623) +- Render the Policies dashboard loading ellipsis instead of displaying the literal Unicode escape text `\u2026`. (#623) +- Make dashboard toggle state follow the same project/local/user config merge as runtime enforcement, remove an enabled policy's opt-out from every contributing scope, and isolate unreadable explicit policy files so one bad file cannot leave the Configure tab loading forever. (#623) + ## 0.0.15-beta.0 — 2026-07-23 ### Dependencies diff --git a/__tests__/actions/get-hooks-config.test.ts b/__tests__/actions/get-hooks-config.test.ts index d1357cad..bda6c64e 100644 --- a/__tests__/actions/get-hooks-config.test.ts +++ b/__tests__/actions/get-hooks-config.test.ts @@ -5,7 +5,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; vi.mock("@/src/hooks/hooks-config", () => ({ - readHooksConfig: () => ({ enabledPolicies: [] }), + readMergedHooksConfig: () => ({ enabledPolicies: [] }), + configuredCustomPolicyPaths: (config: { customPoliciesPaths?: string[]; customPoliciesPath?: string }) => + config.customPoliciesPaths ?? (config.customPoliciesPath ? [config.customPoliciesPath] : []), // The action walks up to the project root like enforcement does; these tests // point cwd straight at the project root, so identity is the right stub. findProjectConfigDir: (start: string) => start, @@ -154,6 +156,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 { diff --git a/__tests__/actions/update-hooks-config.test.ts b/__tests__/actions/update-hooks-config.test.ts new file mode 100644 index 00000000..0b690a0e --- /dev/null +++ b/__tests__/actions/update-hooks-config.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { config, scopedConfigs, writeHooksConfig, writeScopedHooksConfig } = vi.hoisted(() => ({ + config: { enabledPolicies: [] as string[], disabledCustomPolicies: [] as string[] }, + scopedConfigs: { + project: { enabledPolicies: [] as string[], disabledCustomPolicies: [] as string[] }, + local: { enabledPolicies: [] as string[], disabledCustomPolicies: [] as string[] }, + user: { enabledPolicies: [] as string[], disabledCustomPolicies: [] as string[] }, + }, + writeHooksConfig: vi.fn(), + writeScopedHooksConfig: vi.fn(), +})); + +vi.mock("@/src/hooks/hooks-config", () => ({ + readHooksConfig: () => config, + readScopedHooksConfig: (scope: "project" | "local" | "user") => scopedConfigs[scope], + writeHooksConfig, + writeScopedHooksConfig, +})); +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 = []; + for (const scoped of Object.values(scopedConfigs)) scoped.disabledCustomPolicies = []; + writeHooksConfig.mockClear(); + writeScopedHooksConfig.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 () => { + scopedConfigs.user.disabledCustomPolicies = ["convention:project:team-policies.mjs:deny-all"]; + await toggleCustomPolicyAction("convention:project:team-policies.mjs:deny-all", true); + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + { enabledPolicies: [] }, + "user", + expect.any(String), + ); + }); + + it("removes an effective disable from every contributing scope", async () => { + const id = "custom:/tmp/team.js:deny-all"; + scopedConfigs.project.disabledCustomPolicies = [id, "custom:/tmp/team.js:other"]; + scopedConfigs.user.disabledCustomPolicies = [id]; + + await toggleCustomPolicyAction(id, true); + + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + { enabledPolicies: [], disabledCustomPolicies: ["custom:/tmp/team.js:other"] }, + "project", + expect.any(String), + ); + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + { enabledPolicies: [] }, + "user", + expect.any(String), + ); + expect(writeScopedHooksConfig).toHaveBeenCalledTimes(2); + }); + + it("rejects IDs outside custom policy namespaces", async () => { + await expect(toggleCustomPolicyAction("block-sudo", false)).rejects.toThrow("Invalid custom policy ID"); + }); +}); diff --git a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts index a7601d0e..0a895ec6 100644 --- a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts +++ b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts @@ -16,6 +16,94 @@ 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, deny } from "failproofai"; + customPolicies.add({ name: "first", match: { events: ["PreToolUse"] }, fn: async () => deny("blocked by first file") }); + `); + 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 first file"); + + env.writeConfig({ + enabledPolicies: [], + customPoliciesPaths: [firstPath, secondPath], + disabledCustomPolicies: [`custom:${firstPath}:first`], + }); + const secondResult = runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home }); + assertPreToolUseDeny(secondResult); + expect(secondResult.stdout).toContain("blocked by second file"); + }); + it("custom hook that calls deny() → deny decision", () => { const env = createFixtureEnv(); const hookPath = env.writeHook("deny-all.mjs", ` @@ -379,6 +467,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/", () => { diff --git a/__tests__/hooks/hooks-config.test.ts b/__tests__/hooks/hooks-config.test.ts index c9bc8be0..64d77748 100644 --- a/__tests__/hooks/hooks-config.test.ts +++ b/__tests__/hooks/hooks-config.test.ts @@ -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 () => { @@ -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 () => { diff --git a/__tests__/hooks/list-convention-column.test.ts b/__tests__/hooks/list-convention-column.test.ts index 4b5e386e..cfd47eb7 100644 --- a/__tests__/hooks/list-convention-column.test.ts +++ b/__tests__/hooks/list-convention-column.test.ts @@ -132,4 +132,47 @@ describe("listHooks — convention policy column width", () => { rmSync(otherHome, { recursive: true, force: true }); } }); + + it("renders a dashboard-disabled explicit custom policy as OFF", async () => { + const policyPath = join(tmp, "custom.mjs"); + writeFileSync(policyPath, policySource("custom-rule"), "utf8"); + mkdirSync(join(tmp, ".failproofai"), { recursive: true }); + writeFileSync( + join(tmp, ".failproofai", "policies-config.json"), + JSON.stringify({ + enabledPolicies: [], + customPoliciesPaths: [policyPath], + disabledCustomPolicies: [`custom:${policyPath}:custom-rule`], + }), + "utf8", + ); + + await listHooks(tmp); + + const row = lines.find((line) => line.includes("custom-rule")); + expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).toContain("OFF"); + expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).not.toContain("✓"); + }); + + it("renders disabled convention policies as OFF while leaving siblings enabled", async () => { + seed({ + [SHORT_NAME]: `${policySource("enabled-rule")}\n${policySource("disabled-rule")}`, + }); + writeFileSync( + join(tmp, ".failproofai", "policies-config.json"), + JSON.stringify({ + enabledPolicies: [], + disabledCustomPolicies: [`convention:project:${SHORT_NAME}:disabled-rule`], + }), + "utf8", + ); + + await listHooks(tmp); + + const row = lines.find((line) => line.includes(SHORT_NAME)); + const plain = row?.replace(/\x1B\[[0-9;]*m/g, ""); + expect(plain).toContain("MIXED"); + expect(plain).toContain("enabled-rule"); + expect(plain).toContain("disabled-rule (OFF)"); + }); }); diff --git a/__tests__/hooks/loader-path-dedup.test.ts b/__tests__/hooks/loader-path-dedup.test.ts index 441288a4..1ad6df8b 100644 --- a/__tests__/hooks/loader-path-dedup.test.ts +++ b/__tests__/hooks/loader-path-dedup.test.ts @@ -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"); diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 79bffaba..04e5cd45 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -29,6 +29,8 @@ vi.mock("../../src/hooks/hooks-config", () => ({ // hits the filesystem; for these tests the cwd IS the project root. findProjectConfigDir: vi.fn((start: string) => start), readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), + configuredCustomPolicyPaths: vi.fn((config: { customPoliciesPaths?: string[]; customPoliciesPath?: string }) => + config.customPoliciesPaths ?? (config.customPoliciesPath ? [config.customPoliciesPath] : [])), writeHooksConfig: vi.fn(), readScopedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), writeScopedHooksConfig: vi.fn(), @@ -509,7 +511,7 @@ describe("hooks/manager", () => { expect(writeScopedHooksConfig).toHaveBeenCalledWith( expect.objectContaining({ - customPoliciesPath: resolve("/tmp/my-hooks.js"), + customPoliciesPaths: [resolve("/tmp/my-hooks.js")], }), "user", undefined, @@ -518,6 +520,148 @@ describe("hooks/manager", () => { expect(logs.some((l: unknown) => typeof l === "string" && l.includes(resolve("/tmp/my-hooks.js")))).toBe(true); }); + // Installing with --custom is ADDITIVE, mirroring enabledPolicies in the + // same function. Replacing was the surprising half of the old single-path + // field: `-c a` then `-c b` left only `b`, and nothing said `a` had stopped + // applying — the policies just quietly went inert. + it("adds to the configured paths instead of replacing them", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + vi.mocked(loadCustomHooks).mockResolvedValue([ + { name: "test-hook", fn: async () => ({ decision: "allow" as const }) }, + ]); + const { readScopedHooksConfig, writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + vi.mocked(readScopedHooksConfig).mockReturnValue({ + enabledPolicies: [], + customPoliciesPaths: [resolve("/tmp/a.js")], + }); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks([], "user", undefined, false, undefined, "/tmp/b.js"); + + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ + customPoliciesPaths: [resolve("/tmp/a.js"), resolve("/tmp/b.js")], + }), + "user", + undefined, + ); + }); + + it("does not duplicate a path that is already configured", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + vi.mocked(loadCustomHooks).mockResolvedValue([ + { name: "test-hook", fn: async () => ({ decision: "allow" as const }) }, + ]); + const { readScopedHooksConfig, writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + vi.mocked(readScopedHooksConfig).mockReturnValue({ + enabledPolicies: [], + customPoliciesPaths: [resolve("/tmp/a.js")], + }); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks([], "user", undefined, false, undefined, "/tmp/a.js"); + + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ customPoliciesPaths: [resolve("/tmp/a.js")] }), + "user", + undefined, + ); + }); + + // A carried-over path whose file was deleted must not make every future + // install fail — the strict validation below would exit(1) on it. + it("drops a carried-over path whose file no longer exists", async () => { + vi.mocked(readFileSync).mockReturnValue("{}"); + vi.mocked(existsSync).mockImplementation((p: unknown) => String(p) !== resolve("/tmp/gone.js")); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + vi.mocked(loadCustomHooks).mockResolvedValue([ + { name: "test-hook", fn: async () => ({ decision: "allow" as const }) }, + ]); + const { readScopedHooksConfig, writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + vi.mocked(readScopedHooksConfig).mockReturnValue({ + enabledPolicies: [], + customPoliciesPaths: [resolve("/tmp/gone.js")], + }); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks([], "user", undefined, false, undefined, "/tmp/b.js"); + + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ customPoliciesPaths: [resolve("/tmp/b.js")] }), + "user", + undefined, + ); + }); + + // The configure wizard passes replace=true so its selection is authoritative. + it("replaces rather than adds when replace is set", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + vi.mocked(loadCustomHooks).mockResolvedValue([ + { name: "test-hook", fn: async () => ({ decision: "allow" as const }) }, + ]); + const { readScopedHooksConfig, writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + vi.mocked(readScopedHooksConfig).mockReturnValue({ + enabledPolicies: [], + customPoliciesPaths: [resolve("/tmp/a.js")], + }); + + const { installHooks } = await import("../../src/hooks/manager"); + await installHooks([], "user", undefined, false, undefined, "/tmp/b.js", false, undefined, { replace: true }); + + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ customPoliciesPaths: [resolve("/tmp/b.js")] }), + "user", + undefined, + ); + }); + + it("validates and saves multiple explicit custom policy paths", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + vi.mocked(loadCustomHooks).mockResolvedValue([ + { name: "test-hook", fn: async () => ({ decision: "allow" as const }) }, + ]); + const { installHooks } = await import("../../src/hooks/manager"); + const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + + await installHooks(["block-sudo"], "user", undefined, false, undefined, ["/tmp/a.js", "/tmp/b.js"]); + + expect(loadCustomHooks).toHaveBeenCalledTimes(2); + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ customPoliciesPaths: [resolve("/tmp/a.js"), resolve("/tmp/b.js")] }), + "user", + undefined, + ); + }); + + it("treats an empty custom policy path array as absent", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue("{}"); + const { readScopedHooksConfig, writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); + vi.mocked(readScopedHooksConfig).mockReturnValue({ + enabledPolicies: ["block-sudo"], + customPoliciesPath: "/tmp/legacy.js", + }); + const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader"); + const { installHooks } = await import("../../src/hooks/manager"); + + await installHooks(["block-sudo"], "user", undefined, false, undefined, []); + + expect(loadCustomHooks).not.toHaveBeenCalled(); + expect(writeScopedHooksConfig).toHaveBeenCalledWith( + expect.objectContaining({ customPoliciesPath: "/tmp/legacy.js" }), + "user", + undefined, + ); + }); + it("clears customPoliciesPath when removeCustomHooks is true", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue("{}"); @@ -533,6 +677,7 @@ describe("hooks/manager", () => { const [[written]] = vi.mocked(writeScopedHooksConfig).mock.calls; expect((written as unknown as Record).customPoliciesPath).toBeUndefined(); + expect((written as unknown as Record).customPoliciesPaths).toBeUndefined(); const logs = vi.mocked(console.log).mock.calls.map((c) => c[0]); expect(logs.some((l: unknown) => typeof l === "string" && l.includes("Custom hooks path cleared"))).toBe(true); }); diff --git a/__tests__/hooks/new-telemetry.test.ts b/__tests__/hooks/new-telemetry.test.ts index c0f58ab8..eb998320 100644 --- a/__tests__/hooks/new-telemetry.test.ts +++ b/__tests__/hooks/new-telemetry.test.ts @@ -63,6 +63,8 @@ vi.mock("../../src/hooks/integrations", () => ({ vi.mock("../../src/hooks/hooks-config", () => ({ readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), + configuredCustomPolicyPaths: vi.fn((config: { customPoliciesPaths?: string[]; customPoliciesPath?: string }) => + config.customPoliciesPaths ?? (config.customPoliciesPath ? [config.customPoliciesPath] : [])), writeHooksConfig: vi.fn(), readScopedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), writeScopedHooksConfig: vi.fn(), diff --git a/app/actions/get-hooks-config.ts b/app/actions/get-hooks-config.ts index 7bd9aba6..1e2d3ac0 100644 --- a/app/actions/get-hooks-config.ts +++ b/app/actions/get-hooks-config.ts @@ -1,13 +1,13 @@ "use server"; -import { readHooksConfig } from "@/src/hooks/hooks-config"; +import { configuredCustomPolicyPaths, readMergedHooksConfig } from "@/src/hooks/hooks-config"; import { hooksInstalledInSettings, getSettingsPath } from "@/src/hooks/manager"; import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; import { listIntegrations } from "@/src/hooks/integrations"; import { HOOK_SCOPES } from "@/src/hooks/types"; import type { HookScope, IntegrationType } from "@/src/hooks/types"; import { getCliLabel } from "@/lib/cli-registry"; -import { discoverPolicyFiles } from "@/src/hooks/custom-hooks-loader"; +import { customPolicyId, conventionPolicyId, discoverPolicyFiles } from "@/src/hooks/custom-hooks-loader"; import { findProjectConfigDir } from "@/src/hooks/hooks-config"; import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; @@ -36,6 +36,8 @@ export interface CustomPolicyInfo { name: string; description?: string; eventScope?: string; + id: string; + enabled: boolean; } /** @@ -75,6 +77,8 @@ export interface HooksConfigPayload { /** Per-CLI install state at user scope, in `INTEGRATION_TYPES` order. */ clis: CliInstallStatus[]; policies: PolicyInfo[]; + customPoliciesPaths?: string[]; + /** Legacy singular path retained for older dashboard clients. */ customPoliciesPath?: string; customPolicies?: CustomPolicyInfo[]; /** Convention-discovered policy files, project scope first. */ @@ -91,7 +95,10 @@ export interface HooksConfigPayload { * `parseCustomPoliciesFromFile` is a regex read, so a malformed policy file * degrades to "no policies listed" instead of taking the server down. */ -async function discoverConventionPolicies(): Promise { +async function discoverConventionPolicies( + disabled: Set, + explicitPaths: Set, +): Promise { // Two corrections over a bare `process.cwd()`, both of which made real // project policies invisible here while enforcement loaded them fine: // - the standalone server chdir()s into the package on startup, so the real @@ -125,9 +132,13 @@ async function discoverConventionPolicies(): Promise { continue; } for (const filePath of files) { + if (explicitPaths.has(filePath)) continue; let policies: CustomPolicyInfo[] = []; try { - policies = await parseCustomPoliciesFromFile(filePath); + policies = (await parseCustomPoliciesFromFile(filePath)).map((policy) => { + const id = conventionPolicyId(scope, basename(filePath), policy.name); + return { ...policy, id, enabled: !disabled.has(id) }; + }); } catch { // Still list the file — it IS installed and enforcing; we just cannot // read its contents to name the policies. @@ -152,7 +163,7 @@ async function parseCustomPoliciesFromFile(filePath: string): Promise { - const config = readHooksConfig(); + const launchCwd = process.env.FAILPROOFAI_LAUNCH_CWD || process.cwd(); + // Match runtime enforcement: project, local, and user config all + // contribute to the effective policy state shown by the dashboard. + const config = readMergedHooksConfig(launchCwd); const enabledSet = new Set(config.enabledPolicies); + const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []); const installedScopes = HOOK_SCOPES.filter((s) => hooksInstalledInSettings(s)); const primaryScope: HookScope = installedScopes[0] ?? "user"; @@ -195,11 +210,27 @@ export async function getHooksConfigAction(): Promise { currentParams: p.params ? (config.policyParams?.[p.name] ?? {}) : undefined, })); - const customPolicies = config.customPoliciesPath - ? await parseCustomPoliciesFromFile(config.customPoliciesPath) - : undefined; + const customPoliciesPaths = configuredCustomPolicyPaths(config); + const launchRoot = findProjectConfigDir(launchCwd); + const resolvedCustomPaths = customPoliciesPaths.map((path) => resolve(launchRoot, path)); + const parsedCustomPolicies = await Promise.all(resolvedCustomPaths.map(async (path) => { + try { + return (await parseCustomPoliciesFromFile(path)).map((policy) => { + const id = customPolicyId(path, policy.name); + return { ...policy, id, enabled: !disabledCustomPolicies.has(id) }; + }); + } catch { + // Keep the rest of the dashboard usable when one configured file is + // unreadable or disappears between existsSync and readFile. + return []; + } + })); + const customPolicies = parsedCustomPolicies.flat(); - const conventionPolicies = await discoverConventionPolicies(); + const conventionPolicies = await discoverConventionPolicies( + disabledCustomPolicies, + new Set(resolvedCustomPaths), + ); return { enabledPolicies: config.enabledPolicies, @@ -207,8 +238,9 @@ export async function getHooksConfigAction(): Promise { settingsPath, clis, policies, - customPoliciesPath: config.customPoliciesPath, - customPolicies: customPolicies?.length ? customPolicies : undefined, + customPoliciesPaths: customPoliciesPaths.length ? customPoliciesPaths : undefined, + customPoliciesPath: customPoliciesPaths.length === 1 ? customPoliciesPaths[0] : undefined, + customPolicies: customPolicies.length ? customPolicies : undefined, conventionPolicies, }; } diff --git a/app/actions/update-hooks-config.ts b/app/actions/update-hooks-config.ts index 1f9fff5b..f6f85405 100644 --- a/app/actions/update-hooks-config.ts +++ b/app/actions/update-hooks-config.ts @@ -1,8 +1,15 @@ "use server"; -import { readHooksConfig, writeHooksConfig } from "@/src/hooks/hooks-config"; +import { + readHooksConfig, + readScopedHooksConfig, + writeHooksConfig, + writeScopedHooksConfig, +} from "@/src/hooks/hooks-config"; import { trackHookEvent } from "@/src/hooks/hook-telemetry"; import { getInstanceId } from "@/lib/telemetry-id"; +import type { HooksConfig } from "@/src/hooks/policy-types"; +import { HOOK_SCOPES } from "@/src/hooks/types"; export async function togglePolicyAction(name: string, enabled: boolean): Promise { const config = readHooksConfig(); @@ -27,3 +34,33 @@ export async function togglePolicyAction(name: string, enabled: boolean): Promis // Never block the operation } } + +export async function toggleCustomPolicyAction(id: string, enabled: boolean): Promise { + if (!id.startsWith("custom:") && !id.startsWith("convention:")) { + throw new Error("Invalid custom policy ID"); + } + const launchCwd = process.env.FAILPROOFAI_LAUNCH_CWD || process.cwd(); + if (enabled) { + // Enforcement unions disabled IDs across every scope. Remove this ID from + // every scope that contributes it; deleting it only from the user config + // would make the dashboard report ON while runtime still keeps it OFF. + for (const scope of HOOK_SCOPES) { + const scoped = readScopedHooksConfig(scope, launchCwd); + if (!scoped.disabledCustomPolicies?.includes(id)) continue; + const disabled = scoped.disabledCustomPolicies.filter((value) => value !== id); + const next: HooksConfig = { ...scoped, disabledCustomPolicies: disabled }; + if (!disabled.length) delete next.disabledCustomPolicies; + writeScopedHooksConfig(next, scope, launchCwd); + } + return; + } + + // New dashboard opt-outs live in user scope, preserving the existing + // behavior while making them apply to every project using that source ID. + const config = readHooksConfig(); + const disabled = new Set(config.disabledCustomPolicies ?? []); + disabled.add(id); + const next: HooksConfig = { ...config, disabledCustomPolicies: [...disabled] }; + if (!disabled.size) delete next.disabledCustomPolicies; + writeHooksConfig(next); +} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 09a2e9ce..493fc451 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -10,7 +10,7 @@ import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; import { getHooksConfigAction } from "@/app/actions/get-hooks-config"; import type { HooksConfigPayload, PolicyInfo, CustomPolicyInfo } from "@/app/actions/get-hooks-config"; import type { IntegrationType } from "@/src/hooks/types"; -import { togglePolicyAction } from "@/app/actions/update-hooks-config"; +import { toggleCustomPolicyAction, togglePolicyAction } from "@/app/actions/update-hooks-config"; import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web"; import { updatePolicyParamsAction } from "@/app/actions/update-policy-params"; import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; @@ -382,7 +382,7 @@ function DetailPanel({
- + event detail
@@ -1214,6 +1214,26 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install }); }; + const handleCustomToggle = (id: string, currentlyEnabled: boolean) => { + if (!config) return; + setConfig((prev) => prev ? { + ...prev, + customPolicies: prev.customPolicies?.map((p) => p.id === id ? { ...p, enabled: !currentlyEnabled } : p), + conventionPolicies: prev.conventionPolicies.map((entry) => ({ + ...entry, + policies: entry.policies.map((p) => p.id === id ? { ...p, enabled: !currentlyEnabled } : p), + })), + } : prev); + startTransition(async () => { + try { + await toggleCustomPolicyAction(id, !currentlyEnabled); + } catch { + fireActionError("custom_policy_toggle", "Failed to save custom policy change."); + reload(); + } + }); + }; + const handleApply = () => { const { toInstall, toRemove } = pendingChanges; if (toInstall.length === 0 && toRemove.length === 0) return; @@ -1276,7 +1296,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install if (!config) { return (
- Loading\u2026 + Loading{"\u2026"}
); } @@ -1589,7 +1609,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install })} {/* Custom policies section */} - {config.customPoliciesPath && ( + {(config.customPoliciesPaths?.length || config.customPoliciesPath) && (
{/* Section header — matches category header style */}
@@ -1600,26 +1620,28 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install {config.customPolicies?.length ?? 0} detected
- {/* File path row */} -
- - {config.customPoliciesPath} -
+ {(config.customPoliciesPaths ?? [config.customPoliciesPath!]).map((path) => ( +
+ + {path} +
+ ))} {/* Reconfigure notice */}

- Custom policies are always active. To add, remove, or reorder them, edit the JS file above. + Re-run the install command to add, remove, or reorder files.

{/* Rich policy rows — mirrors built-in layout without toggle */} {config.customPolicies?.map((policy) => (
- {/* Invisible spacer matching PolicyToggle dimensions (h-4 w-7) */} -
+
+ handleCustomToggle(policy.id, policy.enabled)} disabled={isPending} /> +
{policy.name}
@@ -1670,7 +1692,9 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install key={`${entry.path}:${policy.name}`} className="flex items-start gap-3 px-4 py-3 border-b border-border/20 hover:bg-muted/20 transition-colors" > -
+
+ handleCustomToggle(policy.id, policy.enabled)} disabled={isPending} /> +
{policy.name}
diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 35f85279..eed99600 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -164,7 +164,7 @@ COMMANDS --scope user|project|local Config scope to write to (default: user) (Codex / Copilot / Cursor / OpenCode / Pi support user|project only) --beta Include beta policies - --custom, -c Path to a JS file of custom policies + --custom, -c Custom policy file (repeat for multiple files) policies --uninstall, -u Disable policies or remove hooks [names...] Specific policy names to disable @@ -172,7 +172,7 @@ COMMANDS Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies - --custom, -c Clear the customPoliciesPath from config + --custom, -c Clear all explicit custom policy paths policies --help, -h Show this help for the policies command @@ -261,7 +261,7 @@ OPTIONS (install) --scope user|project|local Config scope to write to (default: user) (Codex / Copilot / Cursor / OpenCode / Pi support user|project only) --beta Include beta policies - --custom, -c Path to a JS file of custom policies + --custom, -c Custom policy file (repeat for multiple files) (skips interactive prompt; validates file first) OPTIONS (uninstall) @@ -270,7 +270,7 @@ OPTIONS (uninstall) Agent CLI(s) to uninstall from --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies - --custom, -c Clear the customPoliciesPath from config + --custom, -c Clear all explicit custom policy paths EXAMPLES failproofai policies @@ -285,6 +285,7 @@ EXAMPLES failproofai policies --install --cli devin --scope project failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose failproofai policies --install --custom ./my-policies.js + failproofai policies --install --custom ./security.js --custom ./workflow.js failproofai policies -i -c ./my-policies.js failproofai policies --uninstall block-sudo failproofai policies --uninstall --cli codex @@ -312,11 +313,11 @@ EXAMPLES throw new CliError(`Invalid scope: ${scope}. Valid values: user, project, local`); } - const customIdx = subArgs.includes("--custom") ? subArgs.indexOf("--custom") - : subArgs.includes("-c") ? subArgs.indexOf("-c") - : -1; - const customPoliciesPath = customIdx >= 0 ? subArgs[customIdx + 1] : undefined; - if (customIdx >= 0 && (!customPoliciesPath || customPoliciesPath.startsWith("-"))) { + const customIdxs = subArgs + .map((arg, index) => (arg === "--custom" || arg === "-c" ? index : -1)) + .filter((index) => index >= 0); + const customPoliciesPaths = customIdxs.map((index) => subArgs[index + 1]); + if (customPoliciesPaths.some((path) => !path || path.startsWith("-"))) { throw new CliError("Missing path after --custom/-c\nUsage: --custom (e.g. --custom ./my-policies.js)"); } @@ -352,7 +353,7 @@ EXAMPLES // so a policy named "user" isn't incorrectly dropped by the default scope). const consumedIdxs = new Set(); if (scopeIdx >= 0) consumedIdxs.add(scopeIdx + 1); - if (customIdx >= 0) consumedIdxs.add(customIdx + 1); + for (const customIdx of customIdxs) consumedIdxs.add(customIdx + 1); for (const i of cliConsumedIdxs) consumedIdxs.add(i); const flags = new Set(["--install", "-i", "--scope", "--beta", "--custom", "-c", "--cli"]); const unknownInstallFlag = subArgs.find((a) => a.startsWith("-") && !flags.has(a)); @@ -369,7 +370,7 @@ EXAMPLES // prompt — validation of the custom file happens inside installHooks. const policyNames = explicitPolicyNames.length > 0 ? explicitPolicyNames - : customPoliciesPath !== undefined ? [] + : customPoliciesPaths.length > 0 ? [] : undefined; const cli = await resolveTargetClis( @@ -383,7 +384,7 @@ EXAMPLES undefined, includeBeta, undefined, - customPoliciesPath, + customPoliciesPaths.length > 0 ? customPoliciesPaths : undefined, false, cli, ); @@ -393,7 +394,7 @@ EXAMPLES cli_count: cli.length, explicit_policies: explicitPolicyNames.length > 0, include_beta: includeBeta, - has_custom_path: !!customPoliciesPath, + has_custom_path: customPoliciesPaths.length > 0, }); process.exit(0); } diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 795dc884..7e83334b 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -49,7 +49,13 @@ global: block-sudo → { allowPatterns: ["sudo systemctl status"] } resolved: { allowPatterns: ["sudo systemctl status"] } ← falls through to global ``` -**`customPoliciesPath`** - first scope that defines it wins. +**`customPoliciesPaths` / `customPoliciesPath`** - first scope that defines either form wins. + +**`disabledCustomPolicies`** - union across all scopes. The dashboard writes a +source-qualified ID here when you switch off an individual policy from an +explicit or convention policy file. Policies not listed remain enabled by +default; IDs include the source file so same-named policies in multiple files +can be controlled independently. **`llm`** - first scope that defines it wins. diff --git a/docs/custom-policies.mdx b/docs/custom-policies.mdx index d94ce68b..f33a28ed 100644 --- a/docs/custom-policies.mdx +++ b/docs/custom-policies.mdx @@ -69,20 +69,29 @@ Convention policies are the easiest way to build a quality standard for your org # Install with a custom policies file failproofai policies --install --custom ./my-policies.js -# Replace the policies file path +# Replace the custom policy paths failproofai policies --install --custom ./new-policies.js -# Remove the custom policies path from config +# Configure multiple explicit files (loaded in flag order) +failproofai policies --install --custom ./security.js --custom ./workflow.js + +# Remove all explicit custom policy paths from config failproofai policies --uninstall --custom ``` -The resolved absolute path is stored in `policies-config.json` as `customPoliciesPath`. The file is loaded fresh on every hook event - there is no caching between events. +Resolved absolute paths are stored in `policies-config.json` as `customPoliciesPaths`. Repeat `--custom` to configure multiple files. Existing configs using the legacy `customPoliciesPath` field continue to work. Files are loaded fresh on every hook event - there is no caching between events. + +Each registered policy appears with its own toggle in the dashboard. Switching +a policy off records its source-qualified ID in `disabledCustomPolicies`; the +file and its other policies continue to load, while the disabled policy is +excluded before event matching. Policy names duplicated across files have +independent toggles. ### Using both together -Convention policies and the explicit `--custom` file can coexist. Load order: +Convention policies and the explicit `--custom` files can coexist. Load order: -1. Explicit `customPoliciesPath` file (if configured) +1. Explicit `customPoliciesPaths` files (in configured order) 2. Project convention files (`{cwd}/.failproofai/policies/`, alphabetical) 3. User convention files (`~/.failproofai/policies/`, alphabetical) diff --git a/src/hooks/custom-hooks-loader.ts b/src/hooks/custom-hooks-loader.ts index 8a8b2c83..1461433e 100644 --- a/src/hooks/custom-hooks-loader.ts +++ b/src/hooks/custom-hooks-loader.ts @@ -3,7 +3,7 @@ * Supports transitive local imports and `import { ... } from 'failproofai'`. * * Two loading modes: - * 1. Explicit: a single file via `customPoliciesPath` in policies-config.json + * 1. Explicit: files via `customPoliciesPaths` in policies-config.json * 2. Convention: auto-discovered *policies.{js,mjs,ts} files from * .failproofai/policies/ at project and user level (git-hooks style) * @@ -177,6 +177,14 @@ export interface LoadAllResult { conventionSources: ConventionSource[]; } +export function customPolicyId(file: string, name: string): string { + return `custom:${file}:${name}`; +} + +export function conventionPolicyId(scope: "project" | "user", file: string, name: string): string { + return `convention:${scope}:${file}:${name}`; +} + /** * Load ALL custom hooks: explicit customPoliciesPath + convention-discovered files. * @@ -205,7 +213,7 @@ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void { } export async function loadAllCustomHooks( - customPoliciesPath: string | undefined, + customPoliciesPaths: string | string[] | undefined, opts?: { sessionCwd?: string; customPoliciesEnabled?: boolean }, ): Promise { clearCustomHooks(); @@ -228,16 +236,25 @@ export async function loadAllCustomHooks( // convention passes. const loadedPaths = new Set(); - // 1. Explicit customPoliciesPath (existing behavior) - if (customPoliciesPath) { - const absPath = isAbsolute(customPoliciesPath) - ? customPoliciesPath - : resolve(projectRoot, customPoliciesPath); + // 1. Explicit custom policy paths. Accept a string for callers/configs using + // the legacy singular form. + for (const customPoliciesPath of typeof customPoliciesPaths === "string" + ? [customPoliciesPaths] + : customPoliciesPaths ?? []) { + // resolve() also normalizes absolute paths, so aliases containing `.` or + // `..` share a dedup key with convention-discovered canonical paths. + const absPath = resolve(projectRoot, customPoliciesPath); if (existsSync(absPath)) { - loadedPaths.add(absPath); - await loadSingleFile(absPath); + if (!loadedPaths.has(absPath)) { + loadedPaths.add(absPath); + const hooksBefore = getCustomHooks().length; + await loadSingleFile(absPath); + for (const hook of getCustomHooks().slice(hooksBefore)) { + (hook as CustomHook & { __policyId?: string }).__policyId = customPolicyId(absPath, hook.name); + } + } } else { - hookLogWarn(`customPoliciesPath not found: ${absPath}`); + hookLogWarn(`custom policy path not found: ${absPath}`); } } @@ -260,6 +277,9 @@ export async function loadAllCustomHooks( const hooksBefore = getCustomHooks().length; await loadSingleFile(file, { conventionScope: "project" }); const newHooks = getCustomHooks().slice(hooksBefore); + for (const hook of newHooks) { + (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId("project", basename(file), hook.name); + } if (newHooks.length > 0) { conventionSources.push({ scope: "project", @@ -294,6 +314,9 @@ export async function loadAllCustomHooks( const hooksBefore = getCustomHooks().length; await loadSingleFile(file, { conventionScope: "user" }); const newHooks = getCustomHooks().slice(hooksBefore); + for (const hook of newHooks) { + (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId("user", basename(file), hook.name); + } if (newHooks.length > 0) { conventionSources.push({ scope: "user", diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 642f645d..5fb07a8f 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -243,14 +243,20 @@ export async function handleHookEvent( registerBuiltinPolicies(config.enabledPolicies); // Load and register custom hooks (layer 2, after builtins) - const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { + const loadResult = await loadAllCustomHooks( + config.customPoliciesPaths ?? config.customPoliciesPath, + { sessionCwd: session.cwd, customPoliciesEnabled: config.customPoliciesEnabled, - }); + }, + ); const customHooksList = loadResult.hooks; + const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []); const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames)); for (const hook of customHooksList) { + const policyId = (hook as CustomHook & { __policyId?: string }).__policyId; + if (policyId && disabledCustomPolicies.has(policyId)) continue; const hookName = hook.name; const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope; const isConvention = !!conventionScope; diff --git a/src/hooks/hooks-config.ts b/src/hooks/hooks-config.ts index 27f07ddc..e18ff323 100644 --- a/src/hooks/hooks-config.ts +++ b/src/hooks/hooks-config.ts @@ -8,6 +8,13 @@ import type { HooksConfig, ConventionPolicyRecord } from "./policy-types"; import type { HookScope } from "./types"; import { hookLogInfo, hookLogWarn } from "./hook-logger"; +/** Normalize plural and legacy singular explicit policy paths for consumers. */ +export function configuredCustomPolicyPaths( + config: Pick, +): string[] { + return config.customPoliciesPaths ?? (config.customPoliciesPath ? [config.customPoliciesPath] : []); +} + function readConfigAt(path: string): Partial { if (!existsSync(path)) return {}; try { @@ -55,7 +62,7 @@ export function findProjectConfigDir(start: string): string { * Merge rules: * enabledPolicies: union + dedup across all three * policyParams: per-policy key, first scope that defines it wins entirely - * customPoliciesPath: first scope that defines it wins + * customPoliciesPaths/customPoliciesPath: first scope defining either wins * llm: first scope that defines it wins */ export function readMergedHooksConfig(cwd?: string): HooksConfig { @@ -86,9 +93,26 @@ export function readMergedHooksConfig(cwd?: string): HooksConfig { } } - // customPoliciesPath: first scope wins - const customPoliciesPath = - project.customPoliciesPath ?? local.customPoliciesPath ?? global_.customPoliciesPath; + // Explicit custom policy paths: first scope defining either the current + // array form or the legacy singular form wins. + const customPoliciesPaths = [project, local, global_] + .map((scope) => + scope.customPoliciesPaths !== undefined + ? scope.customPoliciesPaths + : scope.customPoliciesPath !== undefined + ? [scope.customPoliciesPath] + : undefined, + ) + .find((paths) => paths !== undefined); + + const disabledCustomPolicies = new Set([ + ...(project.disabledCustomPolicies ?? []), + ...(local.disabledCustomPolicies ?? []), + ...(global_.disabledCustomPolicies ?? []), + ]); + + const customPoliciesEnabled = + project.customPoliciesEnabled ?? local.customPoliciesEnabled ?? global_.customPoliciesEnabled; // llm: first scope wins const llm = project.llm ?? local.llm ?? global_.llm; @@ -96,7 +120,9 @@ export function readMergedHooksConfig(cwd?: string): HooksConfig { return { enabledPolicies: [...enabledSet], ...(Object.keys(mergedParams).length > 0 ? { policyParams: mergedParams } : {}), - ...(customPoliciesPath !== undefined ? { customPoliciesPath } : {}), + ...(customPoliciesPaths !== undefined ? { customPoliciesPaths } : {}), + ...(disabledCustomPolicies.size ? { disabledCustomPolicies: [...disabledCustomPolicies] } : {}), + ...(customPoliciesEnabled !== undefined ? { customPoliciesEnabled } : {}), ...(llm !== undefined ? { llm } : {}), }; } diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index afeaf2a8..6e07d9cf 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -16,7 +16,7 @@ import { } from "./types"; import { claudeCode, getIntegration, settingsPathsFor } from "./integrations"; import { promptPolicySelection } from "./install-prompt"; -import { readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig, syncConventionPolicies, findProjectConfigDir } from "./hooks-config"; +import { configuredCustomPolicyPaths, readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig, syncConventionPolicies, findProjectConfigDir } from "./hooks-config"; import type { HooksConfig, ConventionPolicyRecord } from "./policy-types"; import { BUILTIN_POLICIES } from "./builtin-policies"; import { loadCustomHooks, discoverPolicyFiles } from "./custom-hooks-loader"; @@ -110,7 +110,7 @@ export async function installHooks( cwd?: string, includeBeta = false, source?: string, - customPoliciesPath?: string, + customPoliciesPath?: string | string[], removeCustomHooks = false, cli?: IntegrationType[], options: InstallHooksOptions = {}, @@ -141,7 +141,7 @@ async function installHooksImpl( cwd?: string, includeBeta = false, source?: string, - customPoliciesPath?: string, + customPoliciesPath?: string | string[], removeCustomHooks = false, cli?: IntegrationType[], replace = false, @@ -212,50 +212,88 @@ async function installHooksImpl( selectedPolicies = await promptPolicySelection(preSelected, { includeBeta }); } - // Preserve existing config fields (policyParams, customPoliciesPath, llm) when updating + // Preserve existing config fields when updating. New writes use the plural + // form, while reads continue to accept the legacy singular field. const configToWrite = { ...previousConfig, enabledPolicies: selectedPolicies }; if (removeCustomHooks) { delete configToWrite.customPoliciesPath; - } else if (customPoliciesPath) { - configToWrite.customPoliciesPath = resolve(customPoliciesPath); - // Validate the file before committing it to config - let validatedHooks: Awaited> = []; - try { - validatedHooks = await loadCustomHooks(configToWrite.customPoliciesPath, { strict: true }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - try { - await trackHookEvent(getInstanceId(), "custom_policy_validation_failed", { - scope, - error_type: /not found/i.test(msg) ? "file_not_found" : "load_error", - }); - } catch {} - console.error(`Error: ${msg}`); - process.exit(1); - } - if (validatedHooks.length === 0) { + delete configToWrite.customPoliciesPaths; + } else if ( + customPoliciesPath && + (typeof customPoliciesPath === "string" || customPoliciesPath.length > 0) + ) { + const incoming = (typeof customPoliciesPath === "string" ? [customPoliciesPath] : customPoliciesPath) + .map((path) => resolve(path)); + + // Additive by default, mirroring `enabledPolicies` directly above: a second + // `--custom` ADDS to what is configured rather than silently discarding it. + // Replacing was the surprising half of the old single-path field — running + // `-c a` then `-c b` left only `b`, with nothing printed to say `a` had + // stopped applying. `replace` (passed by the configure wizard) still makes + // the given set authoritative, exactly as it does for enabled policies. + // + // Carried-over paths are filtered by existence first: a file deleted after + // it was configured must not make every future install fail, which is what + // the strict validation below would do. + const carried = ( + previousConfig.customPoliciesPaths ?? + (previousConfig.customPoliciesPath ? [previousConfig.customPoliciesPath] : []) + ) + .map((path) => resolve(path)) + .filter((path) => { + if (existsSync(path)) return true; + console.log(`Dropping custom policies path (file no longer exists): ${path}`); + return false; + }); + + configToWrite.customPoliciesPaths = replace + ? [...new Set(incoming)] + : [...new Set([...carried, ...incoming])]; + delete configToWrite.customPoliciesPath; + + // Validate only what this invocation added. Carried-over paths were + // validated when they were added, and re-validating them here would let one + // stale file block an unrelated install. + for (const path of incoming) { + let validatedHooks: Awaited> = []; try { - await trackHookEvent(getInstanceId(), "custom_policy_validation_failed", { - scope, - error_type: "no_hooks_registered", - }); - } catch {} - console.error( - `Error: no hooks registered in ${customPoliciesPath}. ` + - `Make sure your file calls customPolicies.add(...) at least once.`, + validatedHooks = await loadCustomHooks(path, { strict: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + try { + await trackHookEvent(getInstanceId(), "custom_policy_validation_failed", { + scope, + error_type: /not found/i.test(msg) ? "file_not_found" : "load_error", + }); + } catch {} + console.error(`Error: ${msg}`); + process.exit(1); + } + if (validatedHooks.length === 0) { + try { + await trackHookEvent(getInstanceId(), "custom_policy_validation_failed", { + scope, + error_type: "no_hooks_registered", + }); + } catch {} + console.error( + `Error: no hooks registered in ${path}. ` + + `Make sure your file calls customPolicies.add(...) at least once.`, + ); + process.exit(1); + } + console.log( + `\nValidated ${validatedHooks.length} custom hook(s) from ${path}: ${validatedHooks.map((h) => h.name).join(", ")}`, ); - process.exit(1); } - console.log( - `\nValidated ${validatedHooks.length} custom hook(s): ${validatedHooks.map((h) => h.name).join(", ")}`, - ); } writeScopedHooksConfig(configToWrite, scope, cwd); console.log(`\nEnabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}\n`); if (removeCustomHooks) { console.log("Custom hooks path cleared."); - } else if (configToWrite.customPoliciesPath) { - console.log(`Custom hooks path: ${configToWrite.customPoliciesPath}`); + } else if (configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath) { + const paths = configToWrite.customPoliciesPaths ?? [configToWrite.customPoliciesPath!]; + console.log(`Custom hooks paths: ${paths.join(", ")}`); } // Write hooks for each selected CLI @@ -308,7 +346,7 @@ async function installHooksImpl( arch: arch(), os_release: release(), hostname_hash: hashToId(hostname()), - has_custom_hooks_path: !!(configToWrite.customPoliciesPath), + has_custom_hooks_path: !!(configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath), has_policy_params: !!(configToWrite.policyParams && Object.keys(configToWrite.policyParams).length > 0), param_policy_names: configToWrite.policyParams ? Object.keys(configToWrite.policyParams) : [], command_format: scope === "project" ? "npx" : "absolute", @@ -387,6 +425,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al if (opts?.removeCustomHooks) { const config = readScopedHooksConfig(configScope, cwd); delete config.customPoliciesPath; + delete config.customPoliciesPaths; writeScopedHooksConfig(config, configScope, cwd); console.log("Custom hooks path cleared."); } @@ -526,14 +565,14 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al // Clear config across all three scopes for (const s of HOOK_SCOPES) { const existing = readScopedHooksConfig(s, cwd); - if (existing.enabledPolicies.length > 0 || existing.customPoliciesPath || existing.policyParams) { - const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing; + if (existing.enabledPolicies.length > 0 || existing.customPoliciesPaths?.length || existing.customPoliciesPath || existing.policyParams) { + const { customPoliciesPath: _drop, customPoliciesPaths: _dropMany, policyParams: _dropParams, ...rest } = existing; writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, s, cwd); } } } else if (!HOOK_SCOPES.some((s) => hooksInstalledInSettings(s, cwd))) { const existing = readScopedHooksConfig(configScope, cwd); - const { customPoliciesPath: _drop, policyParams: _dropParams, ...rest } = existing; + const { customPoliciesPath: _drop, customPoliciesPaths: _dropMany, policyParams: _dropParams, ...rest } = existing; writeScopedHooksConfig({ ...rest, enabledPolicies: [] }, configScope, cwd); } } @@ -553,6 +592,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al export async function listHooks(cwd?: string): Promise { const config = readMergedHooksConfig(cwd); const enabledSet = new Set(config.enabledPolicies); + const disabledCustomSet = new Set(config.disabledCustomPolicies ?? []); // Determine which scopes have hooks installed (deduplicate when paths overlap, e.g. cwd === home) const uniqueScopes = deduplicateScopes(HOOK_SCOPES, cwd); @@ -689,19 +729,29 @@ 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); + // Explicit Custom Policies section + const explicitPaths = configuredCustomPolicyPaths(config); + if (explicitPaths.length > 0) { + console.log(`\n \u2500\u2500 Custom Policies \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`); + for (const path of explicitPaths) { + // Enforcement resolves configured paths from the project config root. + // Use the same canonical path here so the ID checked by the CLI exactly + // matches the ID written by the dashboard. + const absPath = resolve(findProjectConfigDir(cwd ?? process.cwd()), path); + console.log(` ${absPath}`); + if (!existsSync(absPath)) { + console.log(` \x1B[31m\u2717 File not found: ${absPath}\x1B[0m`); + continue; + } + const hooks = await loadCustomHooks(absPath); 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 ?? ""}`); + const disabled = disabledCustomSet.has(`custom:${absPath}:${hook.name}`); + const status = disabled ? "\x1B[2m OFF\x1B[0m" : "\x1B[32m\u2713 ON\x1B[0m"; + console.log(` ${status} ${hook.name.padEnd(descColWidth)}${hook.description ?? ""}`); } } } @@ -742,6 +792,9 @@ export async function listHooks(cwd?: string): Promise { // its record is written to both scopes' config files. const targets: ("project" | "user")[] = dir === projectDir && dir === userDir ? ["project", "user"] : dir === projectDir ? ["project"] : ["user"]; + // When both directories are the same, runtime discovery loads the file as + // project convention policy and skips the duplicate user pass. + const policyScope: "project" | "user" = dir === projectDir ? "project" : "user"; const record = (file: string, hooks: string[]) => { for (const t of targets) discovered[t].push({ file, hooks }); }; @@ -761,8 +814,20 @@ export async function listHooks(cwd?: string): Promise { if (hooks.length === 0) { console.log(` \x1B[31m\u2717\x1B[0m ${filename.padEnd(colWidth)}\x1B[31mfailed to load\x1B[0m`); } else { - const hookSummary = hooks.map((h) => h.name).join(", "); - console.log(` \x1B[32m\u2713\x1B[0m ${filename.padEnd(colWidth)}${hooks.length} hook(s): ${hookSummary}`); + const hookStates = hooks.map((hook) => ({ + hook, + disabled: disabledCustomSet.has(`convention:${policyScope}:${filename}:${hook.name}`), + })); + const disabledCount = hookStates.filter((entry) => entry.disabled).length; + const status = disabledCount === 0 + ? "\x1B[32m\u2713 ON\x1B[0m" + : disabledCount === hooks.length + ? "\x1B[2m OFF\x1B[0m" + : "\x1B[33m\u25D0 MIXED\x1B[0m"; + const hookSummary = hookStates + .map(({ hook, disabled }) => `${hook.name}${disabled ? " (OFF)" : ""}`) + .join(", "); + console.log(` ${status} ${filename.padEnd(colWidth)}${hooks.length} hook(s): ${hookSummary}`); } } catch { const filename = basename(file); diff --git a/src/hooks/policy-types.ts b/src/hooks/policy-types.ts index 344a1e8e..44739216 100644 --- a/src/hooks/policy-types.ts +++ b/src/hooks/policy-types.ts @@ -84,7 +84,12 @@ export interface HooksConfig { enabledPolicies: string[]; llm?: LlmConfig; policyParams?: Record>; + /** Explicit custom policy files, loaded in array order. */ + customPoliciesPaths?: string[]; + /** @deprecated Use customPoliciesPaths. Kept for existing config files. */ customPoliciesPath?: string; + /** Source-qualified custom policy IDs that should not be registered. */ + disabledCustomPolicies?: string[]; /** * Turn off convention-discovered custom policies (`.failproofai/policies/`) * without deleting or renaming the files. Absent means enabled — the default