Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
3bdaf30
Make the guard against disabling failproofai the one policy nobody ca…
chhhee10 Aug 20, 2026
d0164ac
Separate what a policy IS from what it does
chhhee10 Aug 20, 2026
7a9718c
Give policies a delivery path that is not the npm package
chhhee10 Aug 20, 2026
f92a6d7
Let a policy that is not a builtin declare params that actually arrive
chhhee10 Aug 20, 2026
f3ea30e
Put a pack on a machine, and refuse the ones that could not work
chhhee10 Aug 21, 2026
c2482de
Take the pack source a person actually has
chhhee10 Aug 21, 2026
074fe6f
Prove a pack stops a tool call, not merely that it loads
chhhee10 Aug 21, 2026
6e97c69
Stop four handler tests reading whoever-runs-them's real pack directory
chhhee10 Aug 21, 2026
43f505f
Stop a pack's verdict being recorded as nobody's and its crash as ours
chhhee10 Aug 21, 2026
ff357a1
Ship the builtins as a pack, and compare it against the compiled ones
chhhee10 Aug 21, 2026
bd924c6
Stop `policies --install` deleting the policies it cannot name
chhhee10 Aug 21, 2026
641f0e6
File a pack's decision under the pack that made it
chhhee10 Aug 21, 2026
9859e78
Install what a pack recommends, not everything it contains
chhhee10 Aug 21, 2026
c40f76f
Install the builtins pack from the package, with no network
chhhee10 Aug 21, 2026
f844335
Refuse when a pack this machine enforces is not running
chhhee10 Aug 21, 2026
53a680d
Deny for the guards a pack declared but could not import
chhhee10 Aug 23, 2026
ebe23e0
Print every command in one visual language, not six
chhhee10 Aug 23, 2026
9e391a9
List everything that enforces, and stop cutting what must be copied
chhhee10 Aug 23, 2026
b8ea9a9
Make a pack something a stranger can publish, and a machine can trust
chhhee10 Aug 23, 2026
e6589a4
Get a policy pack from the dashboard, and stop it claiming what does …
chhhee10 Aug 23, 2026
64fe57b
Say 39 built-in policies, because there are 39
chhhee10 Aug 23, 2026
b8d6926
Let the audit run the policies from the pack, not from the build
chhhee10 Aug 24, 2026
3a7bf10
Credit the policy that actually decided, and see it when it changes
chhhee10 Aug 24, 2026
a89b6b1
Say what the audit is doing, not a count that was never right
chhhee10 Aug 24, 2026
55863c2
Stop warning on every tool call about a state three screens already show
chhhee10 Aug 24, 2026
7bd1f19
Name the policies something a person will type
chhhee10 Aug 24, 2026
69de557
Let people read a pack before they trust it
chhhee10 Aug 24, 2026
a25ef98
Give the dashboard what the terminal just learned
chhhee10 Aug 24, 2026
368c025
Enforce from packs, and stop registering the builtins
chhhee10 Aug 24, 2026
77be767
Take the builtins off every screen, and out of the pack's name
chhhee10 Aug 24, 2026
496cadc
Make the tests fail the way CI does, then stop them failing
chhhee10 Aug 24, 2026
f6fbfed
Close six ways enforcement could switch off without anyone noticing
chhhee10 Aug 24, 2026
b4ae049
Make it one noun, one screen, and one command to publish
chhhee10 Aug 24, 2026
a888bbd
Stop a long policy name from eating its own description
chhhee10 Aug 24, 2026
8280960
Cover the unified surface, and refuse before looking at what is insta…
chhhee10 Aug 24, 2026
f501ba7
Make the new tests survive a machine that has not run the build
chhhee10 Aug 24, 2026
c861207
WIP: stop shipping policies in the package — core is fetched
chhhee10 Aug 24, 2026
9992cfa
Finish the fetch migration, and stop telling users to run the wrong c…
chhhee10 Aug 25, 2026
d9fbc9e
Open 1.0.2-beta.1, because beta.0 is already on the registry
chhhee10 Aug 25, 2026
b175653
Choose the policies at install time, and ask setup one question at a …
chhhee10 Aug 25, 2026
72f73aa
Draw each prompt frame once, and let the wrapper wrap
chhhee10 Aug 25, 2026
ac7bc2c
Open 1.0.2-beta.2, because beta.1 shipped from the commit before this…
chhhee10 Aug 25, 2026
e6b3988
Take "none" for an answer when the picker is emptied
chhhee10 Aug 25, 2026
a4a9f47
Stop the audit cache turning over on a change it cannot see
chhhee10 Aug 25, 2026
bfd9147
Make publishing two commands, and let a pack pick its agents
chhhee10 Aug 25, 2026
4724ba8
Make the one command the docs headline actually run, and stop warning…
internal-cpu Aug 25, 2026
59e6b39
Ask the one question publish cannot answer, instead of requiring the …
internal-cpu Aug 25, 2026
6e73381
Read opencode's database instead of launching a process to ask it
internal-cpu Aug 25, 2026
5049abc
Resume a transcript that grew, instead of reading it from byte zero a…
internal-cpu Aug 25, 2026
c5bb00d
Stop giving our own policies a name only we can type
chhhee10 Aug 26, 2026
5135a47
Never wait forever for a hook payload, and never report a setup that …
chhhee10 Aug 26, 2026
47e00ab
Set a machine up when there is no terminal, instead of refusing
chhhee10 Aug 26, 2026
fd356df
Stop reading a flag's value as the pack to install
chhhee10 Aug 26, 2026
2d8054b
Count a policy as protecting you when it arrived in a pack
chhhee10 Aug 26, 2026
0de8d10
Take the token as the answer, instead of asking for it again
chhhee10 Aug 26, 2026
4ba4df2
Wire hooks without deciding which policies are on
chhhee10 Aug 26, 2026
82bb928
Draw every help screen with one renderer, instead of twelve dialects
chhhee10 Aug 26, 2026
7eb5e98
Cut 1.0.2-beta.7, because beta.6 is already on the registry
chhhee10 Aug 26, 2026
72d04cc
Show a text prompt's default, instead of applying one nobody saw
chhhee10 Aug 26, 2026
45f3267
Date a pack's releases, and record the commit beside the version
chhhee10 Aug 26, 2026
4fdd6aa
Cut 1.0.2-beta.8, for the work that landed after beta.7 shipped
chhhee10 Aug 26, 2026
5b0e6e4
Version a pack by the commit it was built from, not by the date
chhhee10 Aug 26, 2026
952b8fc
Sort the release listing by when each was published
chhhee10 Aug 26, 2026
06b802b
Record the release-listing sort in the changelog
chhhee10 Aug 26, 2026
c3cd1fb
Stop an `add` from silently undoing what a previous one chose
chhhee10 Aug 26, 2026
b13417c
Remove a pack by the name you actually have
chhhee10 Aug 26, 2026
6f2e114
Cut 1.0.2-beta.9, for the pack-remove fix
chhhee10 Aug 26, 2026
47167a5
Close four ways a pack reported itself enforcing more than it did
chhhee10 Aug 27, 2026
6f97996
Check every source that is bundled, not just the one that is named
chhhee10 Aug 27, 2026
b58dbcd
Make a saved policy parameter reach the policy it was saved for
chhhee10 Aug 27, 2026
2b4c3e0
Guard the state by what a command DOES, not by the verbs it names
chhhee10 Aug 27, 2026
eab785c
Decide a glob by what it can reach, not by the letters it happens to …
chhhee10 Aug 27, 2026
79b4ece
Expand braces before compiling the glob, the way a shell does
chhhee10 Aug 27, 2026
47c8229
Bound brace expansion on work, not on depth, and collapse what is left
chhhee10 Aug 27, 2026
825ef71
Translate a bracket expression instead of copying it into a regex
chhhee10 Aug 27, 2026
3f02d42
Compile the extended globs, and scope a fail-closed deny to the agent…
chhhee10 Aug 27, 2026
e2962f5
Stop two failures over-blocking, and a refused resume double-counting
chhhee10 Aug 27, 2026
cbf3b01
Fail closed for every pack behind a shared artifact, and only for wha…
chhhee10 Aug 27, 2026
6ccdd31
Measure every pack behind a shared artifact against what actually loaded
chhhee10 Aug 27, 2026
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,11 @@ sdk/python/.pytest_cache/

# blog drafts (local, not for commit yet)
/blog/

# Generated by `bun run build:pack` — the builtins emitted as a policy pack.
policy-pack/

# `failproofai publish` writes its three release assets here by default.
# They are uploaded, not source — and a publisher running the command inside
# their own repo should not find them staged.
dist-pack/
211 changes: 211 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "1.0.2-beta.0"
version = "1.0.2-beta.9"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
Wherever your agents run, we see it — and we can say no. Failproof hooks 12 agent
harnesses — coding CLIs like Claude Code and Codex, chat gateways like Hermes,
self-hosted assistants like OpenClaw — capturing every run and blocking dangerous
tool calls before they execute. 40 built-in policies. Zero latency. Runs locally.
tool calls before they execute. 39 built-in policies. Zero latency. Runs locally.

</div>

Expand Down Expand Up @@ -140,7 +140,7 @@ failproofai policies --install # or just run `failproofai` and accept the firs
failproofai
```

40 built-in policies activate immediately. Dashboard at `localhost:8020`. Disable the first-run prompt with `FAILPROOFAI_NO_FIRST_RUN=1`.
39 built-in policies activate immediately. Dashboard at `localhost:8020`. Disable the first-run prompt with `FAILPROOFAI_NO_FIRST_RUN=1`.

---

Expand All @@ -160,7 +160,7 @@ failproofai
The first five apply to any agent that can call a tool. The last three are the
developer favourites — coding CLIs are the harness class we cover deepest.

→ [All 40 built-in policies](https://docs.befailproof.ai/policies/builtin)
→ [All 39 built-in policies](https://docs.befailproof.ai/policies/builtin)

---

Expand Down Expand Up @@ -242,7 +242,7 @@ own cluster is available on the Enterprise plan.

| Enforce | |
|---|---|
| [Built-in policies](https://docs.befailproof.ai/policies/builtin) | All 40 policies with parameters |
| [Built-in policies](https://docs.befailproof.ai/policies/builtin) | All 39 policies with parameters |
| [Custom policies](https://docs.befailproof.ai/policies/custom) | Write your own |
| [Configuration](https://docs.befailproof.ai/policies/local-configuration) | Config scopes and merge rules |

Expand Down
37 changes: 24 additions & 13 deletions __tests__/audit/audit-cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,24 @@ describe("audit --help", () => {

it("lists every command a person can type", () => {
const text = plain(render(false));
// The heading carries `failproofai audit`; the rows carry what you add to
// it. Repeating the prefix on every row cost 17 of the 80 columns and was
// what forced the descriptions down to four words a line.
expect(text).toContain("failproofai audit");
for (const command of [
"failproofai audit",
"failproofai audit --schedule [days]",
"failproofai audit --no-schedule",
"failproofai audit --status",
"failproofai audit -h, --help",
"(bare)",
"--schedule [days]",
"--no-schedule",
"--status",
"-h, --help",
]) {
expect(text).toContain(command);
}
// --email modifies --schedule rather than standing alone, so it is named in
// that entry rather than given a row of its own.
// --email modifies --schedule rather than standing alone. It still gets a
// row: as a clause inside --schedule's description it wrapped, leaving the
// flag at the end of one line and `<address>` at the start of the next —
// which is not a spelling anybody can read off the screen or copy. Assert
// it is CONTIGUOUS, which is the property that broke.
expect(text).toContain("--email <address>");
});

Expand All @@ -71,20 +78,24 @@ describe("audit --help", () => {
}

// Every command row and every continuation line shares one description
// column. Derive it from the first row rather than restating the constant,
// column. Derive it from the first row rather than restating a constant,
// so this fails on drift instead of being updated to match it.
const first = lines.find((l) => l.includes("failproofai audit "));
const first = lines.find((l) => l.trim().startsWith("(bare)"));
expect(first).toBeDefined();
const descCol = first!.indexOf("Scan your session history");
expect(descCol).toBeGreaterThan(0);

const continuations = lines.filter(
(l) => l.startsWith(" ".repeat(descCol)) && l.trim().length > 0,
);
// The rows carry six continuation lines between them. A floor rather than
// an exact count, so reworded copy does not fail this — but a regression in
// the padding math moves them off `descCol` entirely and drops it to zero.
expect(continuations.length).toBeGreaterThanOrEqual(6);
// A NON-VACUITY floor, not a layout assertion. The real check is the loop
// below — every continuation starts exactly at `descCol` — and this only
// proves it ran over something. Deliberately well under the count the
// current copy produces: pinning it to the exact number is what made this
// line fail twice for wording changes that improved the screen, once when
// dropping the `failproofai audit` prefix widened the column and again
// when `--email <address>` moved to a row of its own.
expect(continuations.length).toBeGreaterThanOrEqual(2);
for (const line of continuations) {
expect(line[descCol]).not.toBe(" ");
}
Expand Down
205 changes: 205 additions & 0 deletions __tests__/audit/enabled-from-packs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// @vitest-environment node
/**
* "Already protected" vs "slipping through" has to reflect what is actually
* enforcing — including policies that arrived in a PACK.
*
* It read `enabledPolicies` and nothing else. That key predates packs and is
* still written by `policies add <name>`, but `pack-store` never writes it —
* a pack records its selection in `installed.json` instead. So the split had no
* way to learn that anything was enforcing: every finding read as slipping
* through however many policies were installed, and the number the audit leads
* with never moved. Measured before the fix: identical 302 hits and 17 findings
* with nothing installed and with all 38 installed.
*
* NOT about what gets replayed. That stays the compiled builtins on purpose —
* see `initReplay`: an audit is a fixed yardstick, and one that changed shape
* with whatever pack a machine happened to have could not be compared against
* its own history. This is only about which of its findings are already covered.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const SESSION = "aaaaaaaa-1111-2222-3333-444444444444";

let root: string;
let home: string;
let packDir: string;
let origProjects: string | undefined;
let origHome: string | undefined;
let origCwd: string;

/** Tool calls that trip several default builtins. */
function transcript(): string {
const cmds = ["sudo rm -rf /tmp/x", "cat .env", "env | grep SECRET", "git push --force origin main"];
return cmds
.map((c, i) =>
JSON.stringify({
type: "assistant",
uuid: `u${i}`,
parentUuid: i === 0 ? null : `u${i - 1}`,
sessionId: SESSION,
cwd: "/tmp/demo",
timestamp: new Date(2026, 4, 1, 0, 0, i).toISOString(),
message: { role: "assistant", content: [{ type: "tool_use", id: `t${i}`, name: "Bash", input: { command: c } }] },
}),
)
.join("\n") + "\n";
}

/** A manifest in the shape `pack add` really writes — `enabled` absent means
* the whole pack was taken. */
/**
* `fp-home` is resolved through a DYNAMIC import, after the env is set.
*
* A top-level import binds the home directory when the module is first
* evaluated — before `beforeEach` runs — so the fixture was written to one
* place and read from another, and every count came back zero while the same
* code worked perfectly outside the runner.
*/
async function installPack(policyNames: string[], enabled?: string[]): Promise<void> {
// FAILPROOFAI_PACK_DIR, not the fp-home helper.
//
// `packsInstalledFile(home)` takes the home explicitly while the READER
// resolves it from the environment, and the two disagreed under the runner —
// the fixture was written where nothing looked for it, and `readInstalledPacks`
// returned zero packs and zero errors, which is what "no manifest" looks like.
// This variable is read by both sides and by nothing else.
const file = join(packDir, "installed.json");
mkdirSync(dirname(file), { recursive: true });
// A REAL artifact with a matching digest. The manifest reader resolves the
// entry, stats it, and verifies its SHA-256 — a pack that fails any of those
// is dropped, and a dropped pack is indistinguishable from no pack at all,
// which is exactly the state this test is trying to tell apart.
const body = "// pack artifact\n";
const SHA = createHash("sha256").update(body).digest("hex");
const artifacts = join(dirname(file), "artifacts");
mkdirSync(artifacts, { recursive: true });
writeFileSync(join(artifacts, `${SHA}.mjs`), body, "utf8");
writeFileSync(
file,
JSON.stringify({
schemaVersion: 1,
packs: [
{
id: "acme/guards",
version: "1.0.0",
source: "github:acme/guards@1.0.0",
// A real 64-hex digest: the manifest reader rejects anything else
// outright, and a rejected pack looks exactly like no pack at all.
entry: `artifacts/${SHA}.mjs`,
sha256: SHA,
effect: "enforce",
...(enabled ? { enabled } : {}),
policies: policyNames.map((name) => ({
name,
description: name,
category: "General",
defaultEnabled: true,
match: { events: ["PreToolUse"] },
})),
},
],
}),
"utf8",
);
}

async function split(): Promise<{ protectedCount: number; slipping: number }> {
// Imported HERE, not at the top of the file. `fp-home` resolves the home
// directory when its module is first evaluated, so a top-level import binds
// it before `beforeEach` sets FAILPROOFAI_HOME — and every lookup then goes
// to the developer's real home, where there is no fixture pack and the
// answer is always zero.
vi.resetModules();
const { runAudit } = await import("../../src/audit");
const { resetReplay } = await import("../../src/audit/replay");
resetReplay();
const r = await runAudit({ clis: ["claude"], noCache: true });
const isProtected = (x: { source: string; enabledInConfig?: boolean }) =>
x.source === "builtin" && !!x.enabledInConfig;
return {
protectedCount: r.results.filter(isProtected).length,
slipping: r.results.filter((x) => !isProtected(x)).length,
};
}

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "fpai-enabled-"));
home = join(root, "home");
mkdirSync(home, { recursive: true });
packDir = join(home, "packs");
const projects = join(root, "projects", "-tmp-demo");
mkdirSync(projects, { recursive: true });
writeFileSync(join(projects, `${SESSION}.jsonl`), transcript(), "utf8");
origProjects = process.env.CLAUDE_PROJECTS_PATH;
origHome = process.env.FAILPROOFAI_HOME;
origCwd = process.cwd();
process.env.CLAUDE_PROJECTS_PATH = join(root, "projects");
process.env.FAILPROOFAI_HOME = home;
process.env.FAILPROOFAI_PACK_DIR = packDir;
// Out of the repo: its own .failproofai/policies-config.json enables 30
// policies, and `readMergedHooksConfig` merges the PROJECT config — which
// silently supplies an "already protected" set that has nothing to do with
// the test. It read 13 protected on a machine with nothing installed.
process.chdir(root);
});

afterEach(() => {
process.chdir(origCwd);
if (origProjects === undefined) delete process.env.CLAUDE_PROJECTS_PATH;
else process.env.CLAUDE_PROJECTS_PATH = origProjects;
if (origHome === undefined) delete process.env.FAILPROOFAI_HOME;
else process.env.FAILPROOFAI_HOME = origHome;
delete process.env.FAILPROOFAI_PACK_DIR;
rmSync(root, { recursive: true, force: true });
});

describe("what the audit calls already protected", () => {
it("is nothing, on a machine with nothing installed", async () => {
const s = await split();
expect(s.protectedCount).toBe(0);
expect(s.slipping).toBeGreaterThan(0);
});

it("counts a policy that arrived in a pack", async () => {
// The whole bug: this stayed 0 however much was installed.
await installPack(["block-sudo", "block-env-files"]);
const s = await split();
expect(s.protectedCount).toBeGreaterThan(0);
});

it("moves the split rather than just adding rows", async () => {
const before = await split();
await installPack(["block-sudo", "block-env-files"]);
const after = await split();
expect(after.protectedCount).toBeGreaterThan(before.protectedCount);
expect(after.slipping).toBeLessThan(before.slipping);
});

it("takes the WHOLE catalog when no selection was recorded", async () => {
// `enabled: null` means the user took the whole pack. The field records a
// SELECTION, and its absence is not an empty one — reading it as empty
// would report a fully-installed pack as protecting nothing.
await installPack(["block-sudo", "block-env-files"]);
const whole = await split();
await installPack(["block-sudo", "block-env-files"], ["block-sudo"]);
const partial = await split();
expect(whole.protectedCount).toBeGreaterThan(partial.protectedCount);
});

it("still honours the pre-packs key, for a machine on the shim", async () => {
// `policies add <name>` writes `enabledPolicies` to this day, and a machine
// running the migration shim has only that. Reading packs must not replace
// it.
writeFileSync(
join(home, "policies-config.json"),
JSON.stringify({ enabledPolicies: ["block-sudo"] }),
"utf8",
);
const s = await split();
expect(s.protectedCount).toBeGreaterThan(0);
});
});
Loading