Skip to content
Open
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
2 changes: 1 addition & 1 deletion __tests__/audit/replay-source-equivalence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ describe("the audit replays the same policies from either source", () => {
const raw = execFileSync("bun", [probe], { cwd: REPO, encoding: "utf8" }).trim().split("\n").pop() ?? "";
const measured = JSON.parse(raw) as { compiled: string; mixed: string; policies: number };

expect(measured.policies).toBe(38);
expect(measured.policies).toBe(39);
// The pack's function text IS the compiled function text, so the cache key
// does not move and no existing audit result is invalidated.
expect(measured.mixed).toBe(measured.compiled);
Expand Down
2 changes: 1 addition & 1 deletion __tests__/hooks/builtin-pack-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe("builtin pack conformance", () => {
it("packages every builtin except the one packs may not carry", () => {
const expected = POLICY_CATALOG.filter((p) => !p.alwaysOn).map((p) => p.name);
expect(manifest.policies.map((p) => p.name)).toEqual(expected);
expect(manifest.policies).toHaveLength(38);
expect(manifest.policies).toHaveLength(39);
// The omitted one is the guard against disabling failproofai. pack-manifest
// REFUSES a pack declaring alwaysOn, so shipping it here would produce a
// pack our own loader rejects.
Expand Down
9 changes: 5 additions & 4 deletions __tests__/hooks/builtin-policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ describe("hooks/builtin-policies", () => {

describe("BUILTIN_POLICIES", () => {
// 40 before `block-self-pause` was merged into `block-failproofai-commands`.
it("has 39 built-in policies", () => {
expect(BUILTIN_POLICIES).toHaveLength(39);
it("has 40 built-in policies", () => {
expect(BUILTIN_POLICIES).toHaveLength(40);
});

it("has 11 default-enabled policies", () => {
Expand Down Expand Up @@ -2587,10 +2587,11 @@ describe("hooks/builtin-policies", () => {
describe("workflow policy metadata", () => {
const workflowPolicies = BUILTIN_POLICIES.filter((p) => p.category === "Workflow");

it("all 5 workflow policies exist", () => {
expect(workflowPolicies).toHaveLength(5);
it("all 6 workflow policies exist", () => {
expect(workflowPolicies).toHaveLength(6);
const names = workflowPolicies.map((p) => p.name).sort();
expect(names).toEqual([
"require-battery-green-before-stop",
"require-ci-green-before-stop",
"require-commit-before-stop",
"require-no-conflicts-before-stop",
Expand Down
13 changes: 7 additions & 6 deletions __tests__/hooks/policy-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ const EXPECTED_ORDER = [
"warn-background-process", "warn-repeated-tool-calls", "require-commit-before-stop",
"require-push-before-stop", "require-pr-before-stop",
"require-no-conflicts-before-stop", "require-ci-green-before-stop",
"require-battery-green-before-stop",
];

describe("policy catalog / implementation split", () => {
describe("the join", () => {
it("keeps catalog and joined view the same length and order", () => {
expect(POLICY_CATALOG).toHaveLength(39);
expect(BUILTIN_POLICIES).toHaveLength(39);
expect(POLICY_CATALOG).toHaveLength(40);
expect(BUILTIN_POLICIES).toHaveLength(40);
expect(BUILTIN_POLICIES.map((p) => p.name)).toEqual(POLICY_CATALOG.map((e) => e.name));
});

Expand All @@ -59,20 +60,20 @@ describe("policy catalog / implementation split", () => {
expect(holes).toEqual([]);
});

it("assigns 39 DISTINCT implementations, never a shared wrapper", () => {
// The wrapper-collapse guard. `fn: (ctx) => IMPLS[name](ctx)` yields 39
it("assigns 40 DISTINCT implementations, never a shared wrapper", () => {
// The wrapper-collapse guard. `fn: (ctx) => IMPLS[name](ctx)` yields 40
// distinct function OBJECTS with near-identical source text, which freezes
// audit/cache.ts's engineVersion — it then stops changing when policy logic
// changes and stale audit results are served for the full 30-day TTL with
// no symptom anywhere.
expect(new Set(BUILTIN_POLICIES.map((p) => p.fn.toString())).size).toBe(39);
expect(new Set(BUILTIN_POLICIES.map((p) => p.fn.toString())).size).toBe(40);
});

it("has unique names", () => {
// findBuiltin takes the FIRST match and registerPolicy takes the LAST — a
// duplicate silently registers one policy fewer while the audit title comes
// from the other copy.
expect(new Set(BUILTIN_POLICIES.map((p) => p.name)).size).toBe(39);
expect(new Set(BUILTIN_POLICIES.map((p) => p.name)).size).toBe(40);
});

it("adds no fields the catalog did not have", () => {
Expand Down
83 changes: 83 additions & 0 deletions __tests__/hooks/require-battery-green-before-stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PolicyContext } from "../../src/hooks/policy-types";

// Import the builtin policies array to get the policy function
import { BUILTIN_POLICIES } from "../../src/hooks/builtin-policies";

const policy = BUILTIN_POLICIES.find((p) => p.name === "require-battery-green-before-stop")!;

function makeCtx(overrides: Partial<PolicyContext>): PolicyContext {
return {
eventType: "Stop",
payload: {},
...overrides,
} as PolicyContext;
}

function repoWithBattery(exitCode: 0 | 1): string {
const dir = mkdtempSync(join(tmpdir(), "fpai-battery-"));
const scriptDir = join(dir, "scripts", "verify");
mkdirSync(scriptDir, { recursive: true });
const script = join(scriptDir, "battery.sh");
writeFileSync(script, `#!/bin/sh\nexit ${exitCode}\n`);
chmodSync(script, 0o755);
return dir;
}

describe("require-battery-green-before-stop policy", () => {
it("exists in BUILTIN_POLICIES, off by default, Stop-only", () => {
expect(policy).toBeDefined();
expect(policy.defaultEnabled).toBe(false);
expect(policy.match.events).toEqual(["Stop"]);
});

it("allows when no cwd in session (graceful fallback)", async () => {
const result = await policy.fn(makeCtx({}));
expect(result.decision).toBe("allow");
});

it("allows when no battery script exists (fail-open)", async () => {
const dir = mkdtempSync(join(tmpdir(), "fpai-nobattery-"));
const result = await policy.fn(makeCtx({ session: { cwd: dir } }));
expect(result.decision).toBe("allow");
expect(result.reason).toMatch(/no .*battery/i);
});

it("allows when the battery passes", async () => {
const dir = repoWithBattery(0);
const result = await policy.fn(makeCtx({ session: { cwd: dir } }));
expect(result.decision).toBe("allow");
});

it("denies when the battery fails", async () => {
const dir = repoWithBattery(0);
const sub = join(dir, "packages", "app");
mkdirSync(sub, { recursive: true });
writeFileSync(join(dir, "scripts", "verify", "battery.sh"), "#!/bin/sh\nexit 1\n");
chmodSync(join(dir, "scripts", "verify", "battery.sh"), 0o755);
const result = await policy.fn(makeCtx({ session: { cwd: sub } }));
expect(result.decision).toBe("deny");
expect(result.reason).toMatch(/battery/i);
});

it("finds the battery from a nested working directory", async () => {
const dir = repoWithBattery(0);
const sub = join(dir, "a", "b");
mkdirSync(sub, { recursive: true });
const result = await policy.fn(makeCtx({ session: { cwd: sub } }));
expect(result.decision).toBe("allow");
});

it("allows in plan mode without running anything", async () => {
// A red battery repo: allow here proves the plan-mode bypass,
// since the same repo denies without plan mode (see above).
const result = await policy.fn(
makeCtx({ session: { cwd: repoWithBattery(1), permissionMode: "plan" } }),
);
expect(result.decision).toBe("allow");
});
});
33 changes: 33 additions & 0 deletions src/hooks/builtin-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3070,6 +3070,38 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
}
}

function findBatteryUp(startDir: string, maxDepth = 5): string | null {
let dir = resolve(startDir);
for (let i = 0; i <= maxDepth; i++) {
const candidate = join(dir, "scripts", "verify", "battery.sh");
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
// not here — keep climbing
}
const parent = resolve(join(dir, ".."));
if (parent === dir) return null;
dir = parent;
}
return null;
}

function requireBatteryGreenBeforeStop(ctx: PolicyContext): PolicyResult {
if (isPlanMode(ctx)) return allow("Plan mode — no changes made, skipping battery check.");
const cwd = ctx.session?.cwd;
if (!cwd) return allow("No working directory available, skipping battery check.");
const battery = findBatteryUp(cwd);
if (!battery) return allow("No scripts/verify/battery.sh found, skipping battery check.");
try {
execFileSync("sh", [battery, "--l0"], {
cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 120000,
});
} catch {
return deny("Battery L0 red. Run sh scripts/verify/battery.sh --full, fix, then stop.");
}
return allow("Battery L0 green.");
}

// -- Registry --

/**
Expand Down Expand Up @@ -3123,6 +3155,7 @@ const POLICY_IMPLEMENTATIONS: Record<string, PolicyFunction> = {
"require-pr-before-stop": requirePrBeforeStop,
"require-no-conflicts-before-stop": requireNoConflictsBeforeStop,
"require-ci-green-before-stop": requireCiGreenBeforeStop,
"require-battery-green-before-stop": requireBatteryGreenBeforeStop,
};

/**
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/policy-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,13 @@ export const POLICY_CATALOG: PolicyCatalogEntry[] = [
defaultEnabled: false,
category: "Workflow",
},
{
name: "require-battery-green-before-stop",
displayTitle: "Stopped with red local battery",
impact: "Unverified work can't ship.",
description: "Require battery.sh L0 green before stopping; skip when absent",
match: { events: ["Stop"] },
defaultEnabled: false,
category: "Workflow",
},
];