-
Notifications
You must be signed in to change notification settings - Fork 403
Move built-in policies to installable policy packs #735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
a31f0fe
Make the guard against disabling failproofai the one policy nobody ca…
chhhee10 9f1394a
Separate what a policy IS from what it does
chhhee10 b990ff2
Give policies a delivery path that is not the npm package
chhhee10 29836c2
Let a policy that is not a builtin declare params that actually arrive
chhhee10 0101372
Put a pack on a machine, and refuse the ones that could not work
chhhee10 686785c
Take the pack source a person actually has
chhhee10 24abd0f
Prove a pack stops a tool call, not merely that it loads
chhhee10 765d947
Stop four handler tests reading whoever-runs-them's real pack directory
chhhee10 8b2bb8c
Stop a pack's verdict being recorded as nobody's and its crash as ours
chhhee10 eded0c5
Ship the builtins as a pack, and compare it against the compiled ones
chhhee10 1556fed
Stop `policies --install` deleting the policies it cannot name
chhhee10 48ad22e
File a pack's decision under the pack that made it
chhhee10 25d8f08
Install what a pack recommends, not everything it contains
chhhee10 3554263
Install the builtins pack from the package, with no network
chhhee10 3f063a4
Refuse when a pack this machine enforces is not running
chhhee10 c4576e2
docs: date policy migration changelog
chhhee10 1ee89a9
fix: address policy pack review feedback
chhhee10 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // @vitest-environment node | ||
| /** | ||
| * The audit cache key, and the one property that decides whether shipping pack | ||
| * support costs every existing user a cold rescan. | ||
| * | ||
| * `engineVersion` keys on-disk audit cache entries. A machine that has never | ||
| * installed a pack must hash EXACTLY as it did before packs existed — otherwise | ||
| * merely upgrading invalidates the cache and forces a full re-scan of every | ||
| * transcript (~104s, per the note on CACHE_TTL_MS) for a feature nobody is using. | ||
| */ | ||
| import { describe, it, expect, beforeEach, afterEach } from "vitest"; | ||
| import { createHash } from "node:crypto"; | ||
| import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; | ||
|
|
||
| /** Exactly the pre-pack formula, reproduced here so the test is independent of | ||
| * the implementation it checks. */ | ||
| function prePackEngineVersion(): string { | ||
| const blob = BUILTIN_POLICIES.map((p) => `${p.name}|${p.fn.toString()}`).sort().join("\n"); | ||
| return createHash("sha1").update(blob).digest("hex").slice(0, 16); | ||
| } | ||
|
|
||
| const ARTIFACT = "export const hooks = [];\n"; | ||
| const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); | ||
|
|
||
| let root: string; | ||
| let prevEnv: string | undefined; | ||
|
|
||
| /** Fresh module each time — engineVersion memoizes per process. */ | ||
| async function engineVersion(): Promise<string> { | ||
| const { getEngineVersionForTest } = await import("@/src/audit/cache"); | ||
| return getEngineVersionForTest(); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| root = mkdtempSync(join(tmpdir(), "fpai-ev-packs-")); | ||
| mkdirSync(join(root, "artifacts"), { recursive: true }); | ||
| writeFileSync(join(root, "artifacts", `${DIGEST}.mjs`), ARTIFACT); | ||
| prevEnv = process.env.FAILPROOFAI_PACK_DIR; | ||
| process.env.FAILPROOFAI_PACK_DIR = root; | ||
| vi.resetModules(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (prevEnv === undefined) delete process.env.FAILPROOFAI_PACK_DIR; | ||
| else process.env.FAILPROOFAI_PACK_DIR = prevEnv; | ||
| rmSync(root, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| import { vi } from "vitest"; | ||
|
|
||
| function installPack(id: string, version: string, artifact = ARTIFACT): void { | ||
| const digest = createHash("sha256").update(artifact).digest("hex"); | ||
| writeFileSync(join(root, "artifacts", `${digest}.mjs`), artifact); | ||
| writeFileSync( | ||
| join(root, "installed.json"), | ||
| JSON.stringify({ | ||
| schemaVersion: 1, | ||
| packs: [{ | ||
| id, version, | ||
| source: `github:${id}@${version}`, | ||
| entry: `artifacts/${digest}.mjs`, | ||
| sha256: digest, | ||
| policies: [], | ||
| }], | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| describe("engineVersion with packs", () => { | ||
| it("is UNCHANGED from the pre-pack formula when no pack is installed", async () => { | ||
| // The upgrade-cost guarantee. If this ever fails, shipping the change cold- | ||
| // rescans every existing user's whole transcript history. | ||
| expect(await engineVersion()).toBe(prePackEngineVersion()); | ||
| }); | ||
|
|
||
| it("changes once a pack is installed", async () => { | ||
| installPack("acme/finance", "1.2.0"); | ||
| vi.resetModules(); | ||
| expect(await engineVersion()).not.toBe(prePackEngineVersion()); | ||
| }); | ||
|
|
||
| it("changes again when the same pack moves to a new version", async () => { | ||
| installPack("acme/finance", "1.2.0"); | ||
| vi.resetModules(); | ||
| const at120 = await engineVersion(); | ||
| installPack("acme/finance", "1.3.0"); | ||
| vi.resetModules(); | ||
| expect(await engineVersion()).not.toBe(at120); | ||
| }); | ||
|
|
||
| it("changes when only the installed artifact digest changes", async () => { | ||
| installPack("acme/finance", "1.2.0"); | ||
| vi.resetModules(); | ||
| const original = await engineVersion(); | ||
| installPack("acme/finance", "1.2.0", `${ARTIFACT}// patched\n`); | ||
| vi.resetModules(); | ||
| expect(await engineVersion()).not.toBe(original); | ||
| }); | ||
|
|
||
| it("falls back to the builtin-only hash when the manifest is unreadable", async () => { | ||
| // A corrupt manifest must not change the cache key: the packs did not load, | ||
| // so the audit that runs is a builtin-only audit and should hit the cache a | ||
| // builtin-only audit wrote. | ||
| writeFileSync(join(root, "installed.json"), "not json"); | ||
| vi.resetModules(); | ||
| expect(await engineVersion()).toBe(prePackEngineVersion()); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| // @vitest-environment node | ||
| /** | ||
| * A pack, denying a real tool call through the real hook binary. | ||
| * | ||
| * Everything else about packs is tested at the unit level: the manifest parses, | ||
| * the loader tags, the digest verifies. None of that answers the only question | ||
| * that matters to a user — does an installed pack actually STOP the agent — and | ||
| * the layers between (config merge, registration order, per-CLI response shape) | ||
| * are exactly where a policy silently becomes decorative. | ||
| */ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { createHash } from "node:crypto"; | ||
| import { mkdirSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import { runHook, assertAllow, assertPreToolUseDeny } from "../helpers/hook-runner"; | ||
| import { createFixtureEnv } from "../helpers/fixture-env"; | ||
| import { Payloads } from "../helpers/payloads"; | ||
|
|
||
| const ENTRY = ` | ||
| import { customPolicies, allow, deny } from "failproofai"; | ||
| customPolicies.add({ | ||
| name: "block-refunds", | ||
| description: "Block refunds above the approved limit", | ||
| match: { events: ["PreToolUse"] }, | ||
| fn: async (ctx) => (String(ctx.toolInput?.command ?? "").includes("refund") | ||
| ? deny("refunds need a human") | ||
| : allow()), | ||
| }); | ||
| customPolicies.add({ | ||
| name: "block-payouts", | ||
| description: "Block payouts", | ||
| match: { events: ["PreToolUse"] }, | ||
| fn: async (ctx) => (String(ctx.toolInput?.command ?? "").includes("payout") | ||
| ? deny("payouts need a human") | ||
| : allow()), | ||
| }); | ||
| `; | ||
| const DIGEST = createHash("sha256").update(ENTRY).digest("hex"); | ||
|
|
||
| const policy = (name: string) => ({ | ||
| name, description: `d-${name}`, category: "Finance", defaultEnabled: true, | ||
| match: { events: ["PreToolUse"] }, | ||
| }); | ||
|
|
||
| /** Install a pack into the fixture home, the way `pack add` would leave it. */ | ||
| function installPack(home: string, over: Record<string, unknown> = {}, entry = ENTRY): void { | ||
| const digest = createHash("sha256").update(entry).digest("hex"); | ||
| const packs = join(home, ".failproofai", "policies", "packs"); | ||
| mkdirSync(join(packs, "artifacts"), { recursive: true }); | ||
| writeFileSync(join(packs, "artifacts", `${digest}.mjs`), entry, "utf8"); | ||
| writeFileSync( | ||
| join(packs, "installed.json"), | ||
| JSON.stringify({ | ||
| schemaVersion: 1, | ||
| packs: [{ | ||
| id: "acme/finance", version: "1.2.0", source: "github:acme/finance@v1.2.0", | ||
| entry: `artifacts/${digest}.mjs`, sha256: digest, | ||
| policies: [policy("block-refunds"), policy("block-payouts")], | ||
| ...over, | ||
| }], | ||
| }), | ||
| "utf8", | ||
| ); | ||
| } | ||
|
|
||
| const bash = (cmd: string, cwd: string) => Payloads.preToolUse.bash(cmd, cwd); | ||
|
|
||
| describe("pack enforcement, end to end", () => { | ||
| it("denies a tool call a pack policy objects to", () => { | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home); | ||
|
|
||
| const result = runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home }); | ||
| assertPreToolUseDeny(result); | ||
| expect(result.stdout + result.stderr).toContain("refunds need a human"); | ||
| }); | ||
|
|
||
| it("allows what the pack does not object to", () => { | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home); | ||
| assertAllow(runHook("PreToolUse", bash("ls -la", env.cwd), { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("enforces with NO builtin policies enabled — the pack is the only guard", () => { | ||
| // The layering claim made explicit: a pack adds enforcement rather than | ||
| // depending on any builtin being switched on. | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home); | ||
| assertPreToolUseDeny(runHook("PreToolUse", bash("send payout now", env.cwd), { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("registers ONLY the selected policies", () => { | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home, { enabled: ["block-refunds"] }); | ||
|
|
||
| assertPreToolUseDeny(runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home })); | ||
| // Taken out of the pack, so it must not fire even though the artifact | ||
| // registers it. | ||
| assertAllow(runHook("PreToolUse", bash("send payout now", env.cwd), { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("DENIES when the artifact no longer matches its recorded digest", () => { | ||
| // This asserted a clean allow until the fail-closed contract landed, and the | ||
| // comment then said why: failing open was defensible only while compiled | ||
| // builtins enforced underneath. Once a pack can be the only thing standing | ||
| // between an agent and a machine, "the guard you were promised is not | ||
| // running" has to refuse rather than proceed quietly. | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home); | ||
| const packs = join(env.home, ".failproofai", "policies", "packs"); | ||
| writeFileSync(join(packs, "artifacts", `${DIGEST}.mjs`), ENTRY + "\n// tampered\n", "utf8"); | ||
|
|
||
| const result = runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home }); | ||
| assertPreToolUseDeny(result); | ||
| const out = result.stdout + result.stderr; | ||
| expect(out).toContain("acme/finance"); | ||
| // The message must name the human command, because the agent cannot run it: | ||
| // block-failproofai-commands denies every failproofai invocation from a tool | ||
| // call, deliberately and unconditionally. | ||
| expect(out).toContain("failproofai pack list"); | ||
| }); | ||
|
|
||
| it("still denies only where the missing guards applied", () => { | ||
| // The deny is narrow, unlike the daemon's. An unreachable daemon means no | ||
| // evaluation happened at all, so nothing can be known safe; an unloadable | ||
| // pack has an ENUMERABLE set of missing guards, because every declared | ||
| // policy must carry a match. | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home); | ||
| const packs = join(env.home, ".failproofai", "policies", "packs"); | ||
| writeFileSync(join(packs, "artifacts", `${DIGEST}.mjs`), ENTRY + "\n// tampered\n", "utf8"); | ||
|
|
||
| // The pack's policies declare PreToolUse only, so a Stop event is untouched. | ||
| assertAllow(runHook("Stop", { hook_event_name: "Stop", cwd: env.cwd, session_id: "s" } as never, { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("DENIES when a digest-valid artifact cannot be imported", () => { | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home, {}, "export const broken = ;\n"); | ||
|
|
||
| const result = runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home }); | ||
| assertPreToolUseDeny(result); | ||
| expect(result.stdout + result.stderr).toContain("artifact failed to load"); | ||
| }); | ||
|
|
||
| it("does NOT deny for a tampered OBSERVE pack", () => { | ||
| // An observe pack evaluates and discards by construction, so denying on its | ||
| // behalf denies for something that would have allowed. | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home, { effect: "observe" }); | ||
| const packs = join(env.home, ".failproofai", "policies", "packs"); | ||
| writeFileSync(join(packs, "artifacts", `${DIGEST}.mjs`), ENTRY + "\n// tampered\n", "utf8"); | ||
|
|
||
| assertAllow(runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("keeps enforcing builtins when the pack manifest is corrupt", () => { | ||
| // The layering property that makes fail-open defensible at all. | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: ["block-sudo"] }); | ||
| const packs = join(env.home, ".failproofai", "policies", "packs"); | ||
| mkdirSync(packs, { recursive: true }); | ||
| writeFileSync(join(packs, "installed.json"), "not json", "utf8"); | ||
|
|
||
| assertPreToolUseDeny(runHook("PreToolUse", bash("sudo rm -rf /", env.cwd), { homeDir: env.home })); | ||
| }); | ||
|
|
||
| it("runs a pack policy in observe mode without denying, and WITHOUT crashing", () => { | ||
| // The allow is not enough on its own, and this test proved it: the first | ||
| // version of this passed against a real bug. The observe path read | ||
| // `cloudManaged!.id`, which is undefined for a pack, so every non-allow | ||
| // shadow verdict threw — the throw was swallowed by the evaluator, nothing | ||
| // was recorded, and the net result was an allow. Exactly what this asserted. | ||
| // A clean stderr is what separates "observed" from "crashed into an allow". | ||
| const env = createFixtureEnv(); | ||
| env.writeConfig({ enabledPolicies: [] }); | ||
| installPack(env.home, { effect: "observe" }); | ||
|
|
||
| const result = runHook("PreToolUse", bash("issue refund 500", env.cwd), { homeDir: env.home }); | ||
| assertAllow(result); | ||
| expect(result.stderr).not.toMatch(/threw:/); | ||
| expect(result.stderr).not.toMatch(/cloudManaged/); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.