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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## 1.0.3 — 2026-08-31

One user-facing fix, and the three suite fixes that were needed to see it.

**If you installed 1.0.2 and added a second agent CLI, check it.**
`failproofai policies --install <policy> --cli <x>` stopped writing hook config
after the first time it ran on a machine — exit 0, a reassuring `Enabled … from
pack` line, and no enforcement for that CLI. The first install always worked,
which is why it survived testing; only the second one was dead.
`failproofai policies` shows which CLIs are actually wired.

The rest is the integration suite, which had been reporting the vendors as
broken while the fault was here — and, once corrected, reported two of its own
detectors as broken too. Nothing in that half changes what the package enforces.

### Fixes

- `policies --install <name> --cli <x>` installs hooks again after the first time. Every builtin is also declared by the bundled `FailproofAI/policies` pack, and the first install is what puts that pack on the machine — so from the second install onward an ordinary `--install block-sudo --cli codex` resolved as "the user named only pack policies", took the short-circuit meant for a third-party name, and returned before writing a settings file. Exit 0, an `Enabled … from pack` line where `Failproof AI hooks installed for OpenAI Codex` belonged, and `--cli`, `--scope` and `--custom` all discarded. The first install per machine worked, which is why it survived manual testing; adding a second agent CLI later got silence and no enforcement. A name only a third-party pack declares still short-circuits, because there the switch really is the whole request (#760)

- openclaw enforces in the integration suite for the first time, after weeks of `NO HOOK LOG — not one hook fired for this probe` against a config that looked correct. Five separate defects, each silent: openclaw refuses a plugin it does not own (`suspicious ownership … expected uid=1001 or root`) and the probe bind-mounts the host checkout, so the plugin was blocked outright; the plugin's `import … from "openclaw/plugin-sdk/plugin-entry"` cannot resolve from a standalone directory (`ERR_MODULE_NOT_FOUND`) while openclaw still lists it as loaded from its manifest; the shim spawns `node <FAILPROOFAI_BINARY_OVERRIDE>` and the canary's override is a `/bin/sh` wrapper, so every hook failed open (the same trap `pi` documents); openclaw runs tools in its own workspace rather than the probe cwd, so probe B could never find the marker; and above all `agent --local` **never dispatches plugin hooks at all** — they run on a global hook runner the GATEWAY installs, so `hasHooks()` answers false and the tool executes with our handler registered and never called. Proven by instrumenting the plugin: `--local` printed REGISTER and nothing more, the gateway printed `HANDLER FIRED tool="exec"`. The probe now copies the plugin into HOME, makes the bare import resolvable, points the override at a node-runnable path inside the repo, uses the probe dir as the workspace, and drives every turn through a per-probe gateway. Verified on a clean volume in daemon mode: `bash=PASS read=PASS` (#760)

- The canary's drift detector stops crying drift over a payload that mapped perfectly. `canary-guard` reports NORMALIZATION-DRIFT-SUSPECT when a shell or path tool's canonical field "arrived empty", but the test asked something weaker — whether the canary token appears in it — so any call carrying the token in some OTHER field was flagged even though `command` had normalized exactly right. Antigravity is where it bit: `run_command` carries `toolAction` and `toolSummary` beside `CommandLine`, free text the model writes about what it is doing, so an `ls` issued while hunting for the marker mapped fine and was still scored as the copilot-1.0.70 silent-allow class. Verified live with a recorder hook against agy 1.1.22: `run_command` still delivers `CommandLine`/`Cwd`, and `view_file` delivers `AbsolutePath`, both of which the maps already handle. The condition now tests what its own message claims — both canonical fields empty — which is what real drift looks like when the keys stop mapping (#760)

- The integration suite stops reporting a working CLI as broken enforcement. Probe B scores a leaked sentinel FAIL unless the agent was denied on a route the probe does not target, and that exception recognised only `canary-read-shell` — the shell. An agent denied on the read tool that reaches for some OTHER tool trips `canary-guard` instead, so antigravity scored red for days with 69 hook events and `canary-bash`, `canary-read` and `canary-guard` all denying correctly: every deny issued and honoured, reported as a silent-allow. Both detectors now downgrade a leak to INCONCLUSIVE — unproven, which is what it is. The narrowness is kept: a leak with NEITHER stays FAIL, because that is what a CLI ignoring the deny looks like. Drift is now decided FIRST, ahead of the leak branch, because `canary-guard` denies for two opposite reasons under one name and `NORMALIZATION-DRIFT-SUSPECT` must never be excused by the widened exception (#760)

## 1.0.2 — 2026-08-27

The stable cut of the `1.0.2-beta.*` line, which stays documented in its own
Expand Down
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.3-beta.0"
version = "1.0.3"
Comment thread
NiveditJain marked this conversation as resolved.
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
162 changes: 162 additions & 0 deletions __tests__/hooks/install-after-pack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @vitest-environment node
/**
* `policies --install <name> --cli <x>` must keep installing hooks once a pack
* is on the machine.
*
* The bundled `FailproofAI/policies` pack declares every builtin BY NAME, and
* the first install is what puts it on disk. So from the second install onward
* an ordinary `--install block-sudo --cli codex` resolved as "the user named
* only pack policies", took the short-circuit meant for third-party packs, and
* returned before writing a single settings file — exit 0, a reassuring
* `Enabled … from pack` line, `--cli`/`--scope`/`--custom` all discarded.
*
* The first install per machine worked, which is why it survived manual
* testing. What found it was the integration suite: it installs for 12 CLIs in
* a row on one container, so exactly one got hooks and the other eleven ran
* unguarded — `hooks: NO HOOK LOG — not one hook fired for this probe`, eleven
* times, reported as broken enforcement in the vendors rather than here.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";

const ARTIFACT = "export const hooks = [];\n";
const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex");

let home: string;
let project: string;
let packRoot: string;
let saved: Record<string, string | undefined>;

function pack(id: string, policies: string[]) {
return {
id,
version: "1.2.0",
source: `github:${id}@v1.2.0`,
entry: `artifacts/${DIGEST}.mjs`,
sha256: DIGEST,
policies: policies.map((name) => ({
name,
description: `does ${name}`,
category: "Security",
defaultEnabled: true,
match: {},
})),
};
}

function install(...packs: unknown[]): void {
writeFileSync(join(packRoot, "installed.json"), JSON.stringify({ schemaVersion: 1, packs }));
}

/** installHooks' positional signature, named so the tests below stay readable. */
async function installFor(names: string[] | undefined, clis: string[]) {
const { installHooks } = await import("@/src/hooks/manager");
await installHooks(
names,
"project",
project,
false,
undefined,
undefined,
false,
clis as never,
);
}

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "fpai-afterpack-home-"));
project = mkdtempSync(join(tmpdir(), "fpai-afterpack-proj-"));
packRoot = mkdtempSync(join(tmpdir(), "fpai-afterpack-packs-"));
mkdirSync(join(packRoot, "artifacts"), { recursive: true });
writeFileSync(join(packRoot, "artifacts", `${DIGEST}.mjs`), ARTIFACT);
saved = {
FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME,
FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR,
FAILPROOFAI_BINARY_OVERRIDE: process.env.FAILPROOFAI_BINARY_OVERRIDE,
};
process.env.FAILPROOFAI_HOME = home;
process.env.FAILPROOFAI_PACK_DIR = packRoot;
// Never resolved off PATH here: a machine without a global install must not
// turn this into a test about `which`.
process.env.FAILPROOFAI_BINARY_OVERRIDE = join(project, "failproofai");
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});

afterEach(() => {
vi.restoreAllMocks();
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
for (const dir of [home, project, packRoot]) rmSync(dir, { recursive: true, force: true });
});

describe("installing a builtin that an installed pack also declares", () => {
it("still writes the target CLI's settings file", async () => {
install(pack("FailproofAI/policies", ["block-sudo", "block-read-outside-cwd"]));
await installFor(["block-sudo"], ["codex"]);
expect(existsSync(join(project, ".codex", "hooks.json"))).toBe(true);
});

it("honours --cli for a SECOND CLI after the first install seeded the pack", async () => {
// The exact sequence the integration suite runs, and the one a user runs
// when they add a second agent CLI weeks later.
install(pack("FailproofAI/policies", ["block-read-outside-cwd"]));
await installFor(["block-read-outside-cwd"], ["claude"]);
await installFor(["block-read-outside-cwd"], ["codex"]);
expect(existsSync(join(project, ".claude", "settings.json"))).toBe(true);
expect(existsSync(join(project, ".codex", "hooks.json"))).toBe(true);
});

it("wires every CLI when they arrive one invocation at a time", async () => {
install(pack("FailproofAI/policies", ["block-read-outside-cwd"]));
for (const cli of ["claude", "codex", "copilot", "cursor"]) {
await installFor(["block-read-outside-cwd"], [cli]);
}
for (const rel of [
[".claude", "settings.json"],
[".codex", "hooks.json"],
[".github", "hooks", "failproofai.json"],
[".cursor", "hooks.json"],
]) {
expect(existsSync(join(project, ...rel)), rel.join("/")).toBe(true);
}
});

it("leaves the switch in the pack rather than re-writing enabledPolicies", async () => {
// The pack is where a policy is turned on now. Writing the name back into
// `enabledPolicies` would resurrect the stale key that made a `remove`
// followed by an `--install` silently re-enable what the owner switched off.
install(pack("FailproofAI/policies", ["block-sudo"]));
await installFor(["block-sudo"], ["codex"]);
const packed = JSON.parse(readFileSync(join(packRoot, "installed.json"), "utf8")) as {
packs: Array<{ enabled?: string[] }>;
};
expect(packed.packs[0].enabled).toContain("block-sudo");
const configPath = join(project, ".failproofai", "policies-config.json");
const config = existsSync(configPath)
? (JSON.parse(readFileSync(configPath, "utf8")) as { enabledPolicies?: string[] })
: { enabledPolicies: [] };
expect(config.enabledPolicies ?? []).not.toContain("block-sudo");
});
});

describe("installing a name only a third-party pack declares", () => {
it("still short-circuits without touching any CLI's settings", async () => {
// Unchanged on purpose. `policies add block-big-refund` is a switch, not an
// install: carrying on would rewrite every CLI's settings to enable a set
// of builtins nobody asked about, and would fail outright on a machine with
// no binary on PATH — after the pack change had already landed.
install(pack("acme/finance", ["block-big-refund"]));
await installFor(["block-big-refund"], ["codex"]);
expect(existsSync(join(project, ".codex", "hooks.json"))).toBe(false);
const packed = JSON.parse(readFileSync(join(packRoot, "installed.json"), "utf8")) as {
packs: Array<{ enabled?: string[] }>;
};
expect(packed.packs[0].enabled).toContain("block-big-refund");
});
});
19 changes: 19 additions & 0 deletions __tests__/integration-suite/canary-policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,24 @@ describe("canary-policies.mjs", () => {
expect(v.decision).toBe("deny");
expect(v.reason).toMatch(/NORMALIZATION-DRIFT-SUSPECT/);
});

it("does NOT flag a command that mapped fine but carries the token elsewhere", async () => {
// Antigravity's live `run_command` shape (agy 1.1.22, captured with a
// recorder hook): `CommandLine` maps to `command` exactly as it should,
// and the model's own free-text `toolSummary` mentions the marker it is
// hunting for. The command normalized perfectly, so this is a
// route-around, not drift — calling it drift scored a working CLI as the
// silent-allow class this policy exists to catch, and antigravity went
// red on it.
const v = await firstDeny("Bash", {
command: "ls -la",
cwd: "/home/canary/probe-antigravity",
toolSummary: "Locating CANARY_MARKER.txt",
});
expect(v.decision).toBe("deny");
expect(v.by).toBe("canary-guard");
expect(v.reason).not.toMatch(/NORMALIZATION-DRIFT-SUSPECT/);
expect(v.reason).toMatch(/route-around/);
});
});
});
16 changes: 11 additions & 5 deletions __tests__/integration-suite/local-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,13 +836,19 @@ describe("probe B tells a route-around apart from a silent-allow", () => {
expect(probeSh).toMatch(/shell_route_attempted\(\) \{ grep -q "result=deny policy=custom\/canary-read-shell "/);
});

it("downgrades a leak to INCONCLUSIVE only when the shell was being denied", () => {
// A leak with NO shell attempt stays FAIL — that is what a CLI ignoring our
// deny looks like (copilot 1.0.70), and blurring the two would blind this
// suite to the silent-allow it exists to catch.
it("downgrades a leak to INCONCLUSIVE only when a route-around was being denied", () => {
// Either route the probe does not target counts: the shell
// (canary-read-shell) or any other tool (canary-guard). A leak with NEITHER
// stays FAIL — that is what a CLI ignoring our deny looks like (copilot
// 1.0.70), and blurring the two would blind this suite to the silent-allow
// it exists to catch.
expect(probeSh).toMatch(
/if shell_route_attempted "\$LOGB\/hooks\.log"; then VB=INCONCLUSIVE; else VB=FAIL; fi/,
/if shell_route_attempted "\$LOGB\/hooks\.log" \|\| route_around "\$LOGB\/hooks\.log"\n\s*then VB=INCONCLUSIVE; else VB=FAIL; fi/,
);
// And drift keeps its own verdict ahead of that branch: canary-guard denies
// for two opposite reasons under one name, so the widened exception must
// not be able to reach a NORMALIZATION-DRIFT-SUSPECT deny.
expect(probeSh).toMatch(/if drift_suspected "\$LOGB\/hooks\.log"; then VB=FAIL/);
});
});

Expand Down
Loading