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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

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

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

### 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
Expand Down
6 changes: 5 additions & 1 deletion __tests__/actions/get-hooks-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions __tests__/actions/update-hooks-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
126 changes: 126 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,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", `
Expand Down Expand Up @@ -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/", () => {
Expand Down
42 changes: 40 additions & 2 deletions __tests__/hooks/hooks-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe("hooks/hooks-config", () => {
});
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
const config = readMergedHooksConfig(CWD);
expect(config.customPoliciesPath).toBe("/local/hooks.js");
expect(config.customPoliciesPaths).toEqual(["/local/hooks.js"]);
});

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

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

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

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

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

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

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

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

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

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