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

## 1.0.0 — 2026-08-12

The first stable release. Everything below this heading shipped across the
`1.0.0-beta.*` line, which stays documented in its own sections underneath.

Two things are worth stating plainly for anyone arriving at 1.0.0 from a beta.

**Enforcement runs through a background daemon, and a configured machine fails
closed.** `failproofai config` installs `failproofaid` as a system service; from
that point an unreachable daemon DENIES rather than silently falling back to
in-process evaluation, because a guarantee you can remove by stopping a service
is not one. In-process evaluation still exists and is reachable only on a
machine that was never set up. Linux and macOS only — setup refuses on other
platforms rather than completing with a weaker guarantee.

**A policy that cannot be honoured on your CLI is not silently pretended.**
`enforcement-capability.ts` records, per (CLI, event) pair and with its evidence,
whether a deny actually changes the agent's behaviour. Absent means UNVERIFIED,
never "blocked".

### Features

- Cut setup's prose by two thirds. The daemon step spent three lines explaining the warm-worker architecture to somebody about to type a password; the cloud step spent five on what connecting sends. Eleven lines became three — say what is happening and what it costs, drop the mechanism. The cloud screen keeps the one clause that is not explanation ("Sessions include prompts, file contents and command output"), because it is a consent screen and the only place that disclosure is ever made: `describeOutcome` prints "hook activity" afterwards and never mentions transcripts, so compressing it to "telemetry" would be brevity that is really vagueness. Its option hint now says what the cloud GIVES — central monitoring and policy deployment, which is what the key's two scopes buy — rather than "see what your agents did", which the local dashboard already shows and which made connecting look redundant. The same screen names the product rather than the artefact — "New to FailproofAI? Create a key at befailproof.ai/get-started" — and points at get-started rather than the dashboard host, since somebody reading that line has no key and usually no org either, and "No key?" reads as an error state to a person who has simply not signed up yet. (#683)
- Give `failproofai config` a Recommended path, so the common install is two questions instead of five. Setup opened by asking scope, policy bundles, harnesses and cloud of somebody who has just installed the tool and does not yet know what any of those mean — every one of which has a defensible default, so asking all four up front made the person least able to answer do the most work. Recommended is not a shortcut past those decisions, it is a decision taken on their behalf: global scope (a project install guards the one directory the command was run from and silently leaves every other repo unguarded), the CLIs actually detected on the machine, and a named 15-policy set. Customize is the previous wizard unchanged — nothing is removed and nothing is hidden, it stops being the only way through. The cloud question is still asked on both paths. (#683)
- Name what "Recommended" means, in one list with the reasoning attached. `RECOMMENDED_POLICIES` in `policy-presets.ts` is written out rather than derived from `defaultEnabled`, because those answer different questions — `defaultEnabled` seeds a checklist of 40, this answers "what should guard a machine whose owner did not want to choose" — and deriving one from the other would silently reshape the recommended set every time somebody flipped a flag on an unrelated policy. It is the 12 that were already default-on plus **three that were off and should not have been**: `block-rm-rf`, `block-force-push` and `block-secrets-write`. A recommended setup that omits catastrophic deletion and force-push is not recommendable, and both are precisely scoped — `block-rm-rf` only fires at depth ≤2 under `/` or a home directory, exempts `/tmp`, and treats an unresolved `$VAR` target as catastrophic, so `rm -rf node_modules` is untouched; `block-force-push` blocks `--force` and `-f` while explicitly allowing `--force-with-lease` and `--force-if-includes`. Deliberately excluded, each for a stated reason: the `require-*-before-stop` workflow gates (they refuse to let the agent finish until CI is green, and per `enforcement-capability.ts` do not fire at all on hermes or goose), the infra blockers (they break the day job of anyone who runs kubectl), `block-read-outside-cwd` (agents legitimately read outside the repo) and the ten `warn-*` policies (a warning nobody reads is worse than one that was never shown). (#683)
- Give the review screen a taste of the policy set rather than only a count. `Policies : 15 enabled` is a number the user cannot check and, on the recommended path, did not choose. Two names and a count of the rest now sit under it — `block-curl-pipe-sh, block-env-files +13` — which is the same shape `describeSelection` already uses for bundles, and enough to say what KIND of thing these are without turning a four-line review into a thirteen-line one; a screen nobody reads to the bottom conveys less than a short one. The whole review body is rendered dim by the prompt, so it reads as a subtitle to the count rather than competing with it, and it scales unchanged to "Everything" (`block-aws-cli, block-az-cli +38`). Degrades by dropping a name rather than overflowing: `writeLines` truncates with a hard cut and no ellipsis, so an over-long line ends mid-slug and reads as a policy name that does not exist. (#683)
- Union rather than replace when Recommended writes. `installHooks` runs with `replace: true`, so writing the bare 15 would switch OFF anything the user had enabled by hand — turning "give me the sensible defaults" into a reduction in protection, the one direction this must never move. On a fresh machine the union is exactly the 15. The `customPoliciesEnabled` flag is left alone on this path for the same reason: the customize expression evaluates to `false` when no bundle is ticked, and no bundle is ever ticked here, so writing it would disable every `.failproofai/policies` file on disk as a side effect of choosing the default setup. (#683)

### Fixes

- Give a Hermes session one agent id for its whole life. A single session was arriving under two — confirmed on a customer org, `20260812_133702_31ca19f0` under both `hermes-kratos` and `hermes-telegram`, and a cron session under both `hermes-cron` and the bare fallback, from ONE collector with the other producer's rows excluded. The id was derived from the session's own `cwd` and `source` columns and re-read on every poll, and Hermes rewrites those throughout a run (`hermes_state.py` carries ~20 `UPDATE sessions SET …`), so a session split the moment one changed between two polls. The file documented that as a safety property — "session columns are read fresh on every poll" — while the `pending` map directly below states the opposite rule correctly for tool names; reading fresh is right for a name and wrong for an identity. Identity now comes from which DATABASE the session is in: the root keeps the bare `hermes` every deployment already ships under, and `profiles/<name>/state.db` becomes `hermes-<name>`. A path cannot change mid-poll, so this is stable by construction, it keeps the poll function pure (the format contract requires that or re-read rows dedup into duplicates), and it matches the standalone collector's `agent_id_for` so a machine migrating off it is not renamed. Nothing is lost: `hermes_source` and `hermes_cwd` were already on every event, so transport and project stay answerable as filters over one agent's sessions. The regression test polls twice with `cwd` rewritten in between — every existing test polled once, which is why a shipping bug sat behind a green suite. (#683)
- Close a hole in the bundle vocabulary that Recommended had to route around. Four `defaultEnabled` policies — `block-self-pause`, `block-sudo`, `block-curl-pipe-sh` and `block-failproofai-commands` — are in the `Dangerous Commands` category, which no preset covers, and only `block-secrets-write` is rescued from it by the secrets preset's `extra`. So 8 of the 12 default-on policies are reachable by ticking bundles and 4 are not, and since the wizard writes with `replace: true`, a first run that picks bundles produces a machine WITHOUT the two policies that stop the agent disabling failproofai itself. `RECOMMENDED_POLICIES` names all 15 explicitly rather than composing bundles, and `defaultsMissingFromRecommended()` plus a test assert that every default-on policy stays in it — so the day a new one is added, the recommended set cannot silently fall behind the checklist. The bundles themselves still have the gap; closing it needs a fifth bundle or a decision that the self-protection policies are not optional. (#683)
- Make `mode: "oss"` actually stop the daemon talking to the cloud. `config --disconnect` writes that flag and its own comment states the rule: "every cloud code path keys off this flag rather than off 'is a token lying around' precisely so that a disconnected machine is provably silent instead of silent-by-happenstance." That was true of the TypeScript CLI and false of the **daemon**, which is the process that holds the socket and had never read the flag — so a machine put back on OSS whose credential file outlived the decision to leave went on polling and shipping while `--status` reported it disconnected: a restored home, a copied config, a reinstall, a partial cleanup, or simply the layout-1 `cloud.json` fallback. The veto is checked inside `from_file` rather than at the call site, because that function has three exits and a veto guarding only some of them is not a veto. It reads `mode.kind` — `fp-config.ts` persists `mode: { kind }`, an object, and reading it as a string is why an earlier cut of this never fired at all; the tests missed it because their fixtures carried the same wrong assumption as the code, which is worse than no test since they also reported the case as covered. ONLY an explicit `"oss"` vetoes: `mode` postdates the enrolments already in the field, so reading absent or malformed as "oss" would silently disconnect every machine enrolled by an older CLI. `FAILPROOFAI_CLOUD_URL` still wins, since the env path exists so CI and containers work with no files at all. (#683)
- Stop reporting a connection the machine no longer has. Everything `config --status` printed about the cloud connection was read from the credential file, which records what was true at `--connect` time and is never revisited — so a key that was later revoked, expired (`api_key_expiry` exists server-side now), or whose org was disabled left that file byte-for-byte correct while nothing arrived. That is the failure recorded in `crates/failproofaid/src/main.rs`: "a key revoked at 13:05:37 and replaced 37 seconds later was still producing 401s twenty minutes on, with 26 parked batches and a CLI saying `connected`. The only symptom was data that never arrived." The detection was never missing — the uploader writes its verdict into the parked batch's **filename** (`<base>.a<attempt>.c<status>.jsonl`, a rename rather than a sidecar so the record cannot desynchronise from the batch it describes), and `is_auto_retryable()` already excludes a definitively-refused batch from the retry pass because it "will fail identically until the cause is fixed". Nothing had ever read that directory. `--status` now reports from it, and the line replaces the cheerful one rather than being appended after it, because the collector's record of what the server actually said is the only thing on that screen describing **now**. Deliberately silent on batches parked without a client status: those exhausted server-error retries, the retry pass picks them up again, and reporting them would cry wolf over a blip the daemon is already handling. (#683)
- Say it once a session, without anyone having to ask. `--status` only speaks when someone runs it, and the whole failure mode is that nobody knows there is anything to ask about — so the same verdict is now emitted at `SessionStart`, the one point failproofai is already invoked on every CLI, exactly once per session, with a person watching. Costs one directory read per session; needs no flag, no new daemon channel and no user action. It cannot affect the outcome of the hook: the read swallows its own errors, the verdict goes to stderr (`SessionStart` is `observe` on every integration, so a stderr write there cannot block a session), and the exit code is untouched. 401 and 403 are grouped as "credential" — a rejected key and a key without `events:add` have different causes, the same user-visible outcome, and the same fix — and only that group is told to re-run `--connect`. (#683)
- Make a `PostToolUse` deny actually enforce on codex and copilot. Both read a **top-level** `{decision:"block", reason}` at that event and neither reads the `hookSpecificOutput.additionalContext` shape we emitted, so every PostToolUse deny on those two CLIs was evaluated, logged, counted as enforcement in the dashboard — and dropped on the floor. That is the whole `sanitize-*` family plus any custom policy matching `PostToolUse`. Verified rather than inferred, because the rows asserting it were stale in both directions: on codex an A/B live probe at **0.147.0** (identical prompt and hook, only the response shape differing) shows `{decision:"block"}` printing `hook: PostToolUse Blocked` and routing the reason through `codex_core::tools::router` so that it **replaces the tool result the model reads** — the probe's real stdout never reached the model — while the shape we shipped printed `hook: PostToolUse Completed` and the model read that stdout verbatim; on copilot both `postToolUse` call sites in the shipped **1.0.78** bundle gate on `vK = t => t?.decision === "block" && typeof t.reason === "string"`, which fails closed on a missing or non-string reason, so the reason is always sent as a string. This is result-replacement, not prevention — the tool has already run at PostToolUse and its side effect stands — which is exactly the semantic an output-scrubbing policy needs to keep a secret out of the model's context, and is why copilot's "Can block? No" in vendor docs is true of the side effect but not of the result the model reads. Other CLIs are untouched and keep `additionalContext`; a test pins that boundary, since widening the new shape to claude would silently break the one consumer that does read the nested form. (#683)

### Docs

- Record that several `codex` rows in `enforcement-capability.ts` are no longer backed by anything. Re-probing codex for the row above surfaced that its hook sources were restructured after `fe01054a`: `output_parser.rs`, `hook_runtime.rs` and `tools/registry.rs` — cited by name and line number in the `PreToolUse`, `Stop`, `SubagentStop` and `SessionStart` rows — do not exist at those paths in 0.147.0, whose hooks live under `hooks/src/{engine,events}/`. The rows are not known-wrong; they are unverified against any shipping codex, which under this file's own "ABSENT MEANS UNKNOWN" rule is a materially different claim. Marked as due for a re-probe, and the probed-version line now states that a version is part of the claim rather than a footnote. (#683)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## 1.0.0-beta.21 — 2026-08-10

### Fixes
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.0-beta.22"
version = "1.0.0"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,5 +215,4 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). New policies, edge cases, and translat

---

Built by [Nivedit Jain](https://github.com/NiveditJain) and [Nikita Agarwal](https://github.com/nk-ag).
[befailproof.ai](https://befailproof.ai)
Built with ❤️ by [befailproof.ai](https://befailproof.ai) in SF and Bengaluru.
2 changes: 1 addition & 1 deletion __tests__/audit/scheduled-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("the scan stays on this machine", () => {
it("uploads nothing, however much it finds", async () => {
// The scheduled scan reads the CONTENTS of every session transcript on
// disk — prompts, file contents, pasted credentials, command output. It
// once POSTed a counters-only projection of that to Failproof Cloud; that
// once POSTed a counters-only projection of that to FailproofAI Cloud; that
// path is gone, and this pins that it does not come back by accident.
//
// `fetch` is the seam because it is the only way anything here reaches the
Expand Down
20 changes: 20 additions & 0 deletions __tests__/e2e/helpers/hook-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,26 @@ export function assertPostToolUseDeny(result: HookRunResult): void {
expect(output?.additionalContext).toMatch(/Blocked/i);
}

/**
* PostToolUse deny on codex and copilot: a TOP-LEVEL `{decision, reason}`.
*
* Deliberately a second assertion rather than widening `assertPostToolUseDeny`
* to accept either shape. The two shapes are the entire substance of the
* change — codex and copilot read the top-level object and ignore the nested
* one, every other CLI does the reverse — so a helper that passed on whichever
* arrived would go green if a CLI were wired to the wrong one, in either
* direction, which is precisely the regression these tests exist to catch.
*/
export function assertPostToolUseBlockDecision(result: HookRunResult): void {
expect(result.exitCode).toBe(0);
expect(result.parsed?.decision).toBe("block");
expect(result.parsed?.reason).toMatch(/Blocked/i);
// The nested shape must be ABSENT, not merely ignored: copilot's shipped
// guard reads only the top level, so emitting both would leave the file
// asserting a contract no consumer actually exercises.
expect(result.parsed?.hookSpecificOutput).toBeUndefined();
}

export function assertInstruct(result: HookRunResult): void {
expect(result.exitCode).toBe(0);
const output = result.parsed?.hookSpecificOutput as Record<string, unknown> | undefined;
Expand Down
6 changes: 3 additions & 3 deletions __tests__/e2e/hooks/codex-integration.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
runHook,
assertAllow,
assertPreToolUseDeny,
assertPostToolUseDeny,
assertPostToolUseBlockDecision,
assertPermissionRequestDeny,
} from "../helpers/hook-runner";
import { CodexPayloads } from "../helpers/payloads";
Expand Down Expand Up @@ -95,7 +95,7 @@ describe("E2E: Codex integration — hook protocol", () => {
}
});

it("PostToolUse: deny emits additionalContext (Claude-compatible JSON shape)", () => {
it("PostToolUse: deny emits a top-level {decision:\"block\"}, not additionalContext", () => {
const env = createCodexEnv();
try {
writeConfig(env.cwd, ["sanitize-jwt"]);
Expand All @@ -108,7 +108,7 @@ describe("E2E: Codex integration — hook protocol", () => {
),
{ homeDir: env.home, cli: "codex" },
);
assertPostToolUseDeny(result);
assertPostToolUseBlockDecision(result);
} finally {
env.cleanup();
}
Expand Down
Loading