diff --git a/__tests__/audit/replay-source-equivalence.test.ts b/__tests__/audit/replay-source-equivalence.test.ts index c77af845..64e10080 100644 --- a/__tests__/audit/replay-source-equivalence.test.ts +++ b/__tests__/audit/replay-source-equivalence.test.ts @@ -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); diff --git a/__tests__/hooks/builtin-pack-conformance.test.ts b/__tests__/hooks/builtin-pack-conformance.test.ts index 36f6a228..7e0b9e19 100644 --- a/__tests__/hooks/builtin-pack-conformance.test.ts +++ b/__tests__/hooks/builtin-pack-conformance.test.ts @@ -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. diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index cd3c4500..0e6034a4 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -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", () => { @@ -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", diff --git a/__tests__/hooks/policy-catalog.test.ts b/__tests__/hooks/policy-catalog.test.ts index 9d4529b0..a0a7c96d 100644 --- a/__tests__/hooks/policy-catalog.test.ts +++ b/__tests__/hooks/policy-catalog.test.ts @@ -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)); }); @@ -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", () => { diff --git a/__tests__/hooks/require-battery-green-before-stop.test.ts b/__tests__/hooks/require-battery-green-before-stop.test.ts new file mode 100644 index 00000000..1e9483ee --- /dev/null +++ b/__tests__/hooks/require-battery-green-before-stop.test.ts @@ -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 { + 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"); + }); +}); diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 8ca25220..a55e5476 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -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 -- /** @@ -3123,6 +3155,7 @@ const POLICY_IMPLEMENTATIONS: Record = { "require-pr-before-stop": requirePrBeforeStop, "require-no-conflicts-before-stop": requireNoConflictsBeforeStop, "require-ci-green-before-stop": requireCiGreenBeforeStop, + "require-battery-green-before-stop": requireBatteryGreenBeforeStop, }; /** diff --git a/src/hooks/policy-catalog.ts b/src/hooks/policy-catalog.ts index 7659772b..54b4e2ac 100644 --- a/src/hooks/policy-catalog.ts +++ b/src/hooks/policy-catalog.ts @@ -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", + }, ];