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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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
41 changes: 41 additions & 0 deletions __tests__/actions/update-hooks-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
29 changes: 29 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,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", `
Expand Down Expand Up @@ -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/", () => {
Expand Down
34 changes: 28 additions & 6 deletions app/actions/get-hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,6 +36,8 @@ export interface CustomPolicyInfo {
name: string;
description?: string;
eventScope?: string;
id: string;
enabled: boolean;
}

/**
Expand Down Expand Up @@ -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<ConventionPolicyFile[]> {
async function discoverConventionPolicies(
disabled: Set<string>,
customPoliciesPath?: string,
): Promise<ConventionPolicyFile[]> {
// 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
Expand All @@ -111,6 +116,9 @@ async function discoverConventionPolicies(): Promise<ConventionPolicyFile[]> {
];

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
Expand All @@ -125,9 +133,16 @@ async function discoverConventionPolicies(): Promise<ConventionPolicyFile[]> {
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.
Expand All @@ -152,7 +167,7 @@ async function parseCustomPoliciesFromFile(filePath: string): Promise<CustomPoli
const eventScope = eventsMatch
? eventsMatch[1].replace(/["'`\s]/g, "").split(",").filter(Boolean).join(", ")
: undefined;
policies.push({ name: nameMatch[1], description: descMatch?.[1], eventScope });
policies.push({ name: nameMatch[1], description: descMatch?.[1], eventScope, id: "", enabled: true });
}
return policies;
}
Expand All @@ -165,6 +180,7 @@ function buildEventScope(match: { events?: string[]; toolNames?: string[] }): st

export async function getHooksConfigAction(): Promise<HooksConfigPayload> {
const config = readHooksConfig();
const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
const enabledSet = new Set(config.enabledPolicies);

const installedScopes = HOOK_SCOPES.filter((s) => hooksInstalledInSettings(s));
Expand Down Expand Up @@ -196,10 +212,16 @@ export async function getHooksConfigAction(): Promise<HooksConfigPayload> {
}));

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,
Expand Down
14 changes: 14 additions & 0 deletions app/actions/update-hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const config = readHooksConfig();
Expand All @@ -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<void> {
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);
}
31 changes: 27 additions & 4 deletions app/policies/hooks-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) */}
<div className="h-4 w-7 shrink-0 mt-0.5" />
<div className="mt-0.5 shrink-0">
<PolicyToggle enabled={policy.enabled} onChange={() => handleCustomToggle(policy.id, policy.enabled)} disabled={isPending} />
</div>
<div className="flex items-center gap-1.5 min-w-0 w-56 shrink-0 mt-0.5">
<span className="text-xs font-mono text-foreground truncate">{policy.name}</span>
</div>
Expand Down Expand Up @@ -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"
>
<div className="h-4 w-7 shrink-0 mt-0.5" />
<div className="mt-0.5 shrink-0">
<PolicyToggle enabled={policy.enabled} onChange={() => handleCustomToggle(policy.id, policy.enabled)} disabled={isPending} />
</div>
<div className="flex items-center gap-1.5 min-w-0 w-56 shrink-0 mt-0.5">
<span className="text-xs font-mono text-foreground truncate">{policy.name}</span>
</div>
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/custom-hooks-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/hooks/hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,26 @@ 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<string>([
...(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;

return {
enabledPolicies: [...enabledSet],
...(Object.keys(mergedParams).length > 0 ? { policyParams: mergedParams } : {}),
...(customPoliciesPath !== undefined ? { customPoliciesPath } : {}),
...(disabledCustomPolicies.size > 0 ? { disabledCustomPolicies: [...disabledCustomPolicies] } : {}),
...(customPoliciesEnabled !== undefined ? { customPoliciesEnabled } : {}),
...(llm !== undefined ? { llm } : {}),
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/policy-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export interface HooksConfig {
llm?: LlmConfig;
policyParams?: Record<string, Record<string, unknown>>;
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
Expand Down