diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac1e427..b156773c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.15-beta.1 — 2026-07-29 + +### Features +- Add independent dashboard toggles for every policy registered by an explicit custom policy file or a convention policy file. Policies stay enabled by default; disabled policies are stored as source-qualified IDs so identically named policies in different files do not affect each other, and toggling one policy does not disable the other policies declared in the same file. + ## 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..548dfa09 100644 --- a/__tests__/actions/get-hooks-config.test.ts +++ b/__tests__/actions/get-hooks-config.test.ts @@ -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 { diff --git a/__tests__/actions/update-hooks-config.test.ts b/__tests__/actions/update-hooks-config.test.ts new file mode 100644 index 00000000..a2bce201 --- /dev/null +++ b/__tests__/actions/update-hooks-config.test.ts @@ -0,0 +1,41 @@ +// @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:deny-all", false); + expect(writeHooksConfig).toHaveBeenCalledWith({ + enabledPolicies: [], + disabledCustomPolicies: ["custom:deny-all"], + }); + }); + + it("removes the config 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"); + }); +}); diff --git a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts index a7601d0e..3835a76e 100644 --- a/__tests__/e2e/hooks/custom-hooks.e2e.test.ts +++ b/__tests__/e2e/hooks/custom-hooks.e2e.test.ts @@ -16,6 +16,21 @@ 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: [], + customPoliciesPath: hookPath, + disabledCustomPolicies: ["custom:disabled-deny"], + }); + assertAllow(runHook("PreToolUse", Payloads.preToolUse.bash("ls", env.cwd), { homeDir: env.home })); + }); + it("custom hook that calls deny() → deny decision", () => { const env = createFixtureEnv(); const hookPath = env.writeHook("deny-all.mjs", ` @@ -379,6 +394,20 @@ 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 })); + }); + // ── Basic discovery and execution ───────────────────────────────────────── it("project convention policy fires: deny hook in .failproofai/policies/", () => { diff --git a/app/actions/get-hooks-config.ts b/app/actions/get-hooks-config.ts index 7bd9aba6..b17ed7fb 100644 --- a/app/actions/get-hooks-config.ts +++ b/app/actions/get-hooks-config.ts @@ -7,7 +7,7 @@ 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; } /** @@ -91,7 +93,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, + customPoliciesPath?: string, +): 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 @@ -111,6 +116,9 @@ async function discoverConventionPolicies(): Promise { ]; const out: ConventionPolicyFile[] = []; + const explicitPath = customPoliciesPath + ? resolve(findProjectConfigDir(launchCwd), customPoliciesPath) + : undefined; for (const { scope, dir } of dirs) { // Isolate per directory and per file. `parseCustomPoliciesFromFile` guards // non-existence but `readFile` still throws on EACCES, a file deleted @@ -125,9 +133,16 @@ async function discoverConventionPolicies(): Promise { continue; } for (const filePath of files) { + // Runtime loads an explicitly configured file only once and treats it as + // explicit even when it also matches the convention. Mirror that here so + // the dashboard cannot show a second toggle that controls no runtime hook. + if (filePath === explicitPath) 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 +167,7 @@ async function parseCustomPoliciesFromFile(filePath: string): Promise { const config = readHooksConfig(); + const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []); const enabledSet = new Set(config.enabledPolicies); const installedScopes = HOOK_SCOPES.filter((s) => hooksInstalledInSettings(s)); @@ -196,10 +212,16 @@ export async function getHooksConfigAction(): Promise { })); const customPolicies = config.customPoliciesPath - ? await parseCustomPoliciesFromFile(config.customPoliciesPath) + ? (await parseCustomPoliciesFromFile(config.customPoliciesPath)).map((policy) => { + const id = customPolicyId(policy.name); + return { ...policy, id, enabled: !disabledCustomPolicies.has(id) }; + }) : undefined; - const conventionPolicies = await discoverConventionPolicies(); + const conventionPolicies = await discoverConventionPolicies( + disabledCustomPolicies, + config.customPoliciesPath, + ); return { enabledPolicies: config.enabledPolicies, diff --git a/app/actions/update-hooks-config.ts b/app/actions/update-hooks-config.ts index 1f9fff5b..33a16a69 100644 --- a/app/actions/update-hooks-config.ts +++ b/app/actions/update-hooks-config.ts @@ -3,6 +3,7 @@ import { readHooksConfig, writeHooksConfig } 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"; export async function togglePolicyAction(name: string, enabled: boolean): Promise { const config = readHooksConfig(); @@ -27,3 +28,16 @@ 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 config = readHooksConfig(); + const disabled = new Set(config.disabledCustomPolicies ?? []); + if (enabled) disabled.delete(id); + else disabled.add(id); + const next: HooksConfig = { ...config, disabledCustomPolicies: [...disabled] }; + if (disabled.size === 0) delete next.disabledCustomPolicies; + writeHooksConfig(next); +} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 09a2e9ce..7cbddbed 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"; @@ -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; @@ -1618,8 +1638,9 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install key={policy.name} className="flex items-start gap-3 px-4 py-3 border-b border-border/20 hover:bg-muted/20 transition-colors" > - {/* Invisible spacer matching PolicyToggle dimensions (h-4 w-7) */} -
+
+ handleCustomToggle(policy.id, policy.enabled)} disabled={isPending} /> +
{policy.name}
@@ -1670,7 +1691,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/docs/configuration.mdx b/docs/configuration.mdx index 795dc884..d13cd798 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -51,6 +51,11 @@ resolved: { allowPatterns: ["sudo systemctl status"] } ← falls through to glo **`customPoliciesPath`** - first scope that defines it wins. +**`disabledCustomPolicies`** - union across all scopes. The dashboard writes +source-qualified policy IDs here when you switch off an individual policy from +an explicit or convention policy file. Custom policies not listed here remain +enabled by default. + **`llm`** - first scope that defines it wins. --- diff --git a/src/hooks/custom-hooks-loader.ts b/src/hooks/custom-hooks-loader.ts index 8a8b2c83..d42a6525 100644 --- a/src/hooks/custom-hooks-loader.ts +++ b/src/hooks/custom-hooks-loader.ts @@ -177,6 +177,15 @@ export interface LoadAllResult { conventionSources: ConventionSource[]; } +/** IDs used by config and the dashboard to address policies unambiguously. */ +export function customPolicyId(name: string): string { + return `custom:${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. * @@ -235,7 +244,11 @@ export async function loadAllCustomHooks( : resolve(projectRoot, customPoliciesPath); if (existsSync(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(hook.name); + } } else { hookLogWarn(`customPoliciesPath not found: ${absPath}`); } @@ -260,6 +273,11 @@ 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 +312,11 @@ 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..37aeec8a 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -248,9 +248,12 @@ export async function handleHookEvent( 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..9f1a8cef 100644 --- a/src/hooks/hooks-config.ts +++ b/src/hooks/hooks-config.ts @@ -90,6 +90,17 @@ export function readMergedHooksConfig(cwd?: string): HooksConfig { const customPoliciesPath = project.customPoliciesPath ?? local.customPoliciesPath ?? global_.customPoliciesPath; + // Custom policies are default-on, so disabled IDs compose as a union across + // scopes just like enabled builtin policy IDs. + 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; @@ -97,6 +108,8 @@ export function readMergedHooksConfig(cwd?: string): HooksConfig { enabledPolicies: [...enabledSet], ...(Object.keys(mergedParams).length > 0 ? { policyParams: mergedParams } : {}), ...(customPoliciesPath !== undefined ? { customPoliciesPath } : {}), + ...(disabledCustomPolicies.size > 0 ? { disabledCustomPolicies: [...disabledCustomPolicies] } : {}), + ...(customPoliciesEnabled !== undefined ? { customPoliciesEnabled } : {}), ...(llm !== undefined ? { llm } : {}), }; } diff --git a/src/hooks/policy-types.ts b/src/hooks/policy-types.ts index 344a1e8e..984d5d9e 100644 --- a/src/hooks/policy-types.ts +++ b/src/hooks/policy-types.ts @@ -85,6 +85,11 @@ export interface HooksConfig { llm?: LlmConfig; policyParams?: Record>; customPoliciesPath?: string; + /** + * Stable source-qualified IDs for custom policies the user has switched off. + * Custom policies remain enabled by default, preserving drop-in behaviour. + */ + disabledCustomPolicies?: string[]; /** * Turn off convention-discovered custom policies (`.failproofai/policies/`) * without deleting or renaming the files. Absent means enabled — the default