[fp-cli] fp policies, fp fleet and fp guardrails — the dashboard's enforcement pages from a terminal - #727
Conversation
Brings the dashboard's three cloud-managed-policy pages to the CLI, so a person
or an agent can do from a terminal what previously needed a browser: write a
policy, put it on machines, and see what it blocked.
Three commands because they are three jobs, split the way the dashboard splits
them — `/policies` authors a version, `/enforcement` decides which machines run
it, `/guardrails` reports what happened. Folding them into one would merge
"what we intended" with "what occurred", which is the distinction the pages
exist to keep.
## The dangerous part, and what the CLI does about it
`PUT /enforcement/deployments/{id}` REPLACES a machine's whole policy set. No
merge, no server-side lock. The dashboard has no deploy form precisely because
of this — it edits the machine's own current set, since a form that asks you to
re-tick policies silently drops whatever you forget.
So `fleet deploy` is a read-modify-write: it reads what the machine runs, applies
`--add`/`--remove`, shows the FULL resulting set, and writes that. `--set` is the
only way to drop what you did not name, and is refused alongside `--add`.
Three further guards, each for a way this loses work silently:
* A bare `--add` of a policy the machine already runs keeps its PINNED version
rather than moving to the newest. A pin is deliberate; upgrading a fleet on
a command whose author was reordering is not.
* The diff shows unchanged rows. The write replaces everything, so the set on
screen is the set that will exist — hiding untouched rows hides exactly the
ones a mistake drops.
* The generation read before the write must come back as `base + 1`. Anything
else means somebody deployed in between, and a replace does not merge, so
their change is already gone. The CLI refuses instead of reporting success.
(`lib/enforcementFleet.ts`'s `staleness()` does the same check, after the
fact; doing it before is the difference between a warning and a save.)
## Session-only, deliberately
Every route here is ROOT-ONLY on the server — absent from `/v1` because `/v1` is
internet-facing and these are operator writes. The commands refuse `--api-key`
up front via `deny_in_key_mode` rather than translating a path that would 404,
and `enforcement` is classified in `_V1_NO_EQUIVALENT` so the anti-drift test
that guards that table stays honest.
## Input and output
Policy source arrives as a path, `@path`, a pipe, `-`, or an interactive paste
when stdin is a terminal — five shapes because that is where people keep a file
they are about to publish, and refusing the clipboard means "save it first" for
the most common one-off.
Every command supports `--json`, in the SERVER's shape plus what the CLI
computed (the deploy plan, the drift flag). Model `to_dict()` rather than
`vars()`: the latter leaks Python snake_case into a contract that is camelCase
everywhere else, which a harness discovers at runtime rather than in review.
Tests: 42 covering the planner, the race check and source resolution — the pure
logic, because that is where a wrong answer destroys a fleet's policy set. 836
pass overall.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from driving the commands against a running deployment rather than reading them. **A deploy to an unknown machine silently succeeded.** The server accepts a deploy to ANY id — that is how a machine can be pre-staged before it ever polls — so `fp fleet deploy no-such-box --add x` returned 0 and created `no-such-box`, carrying policies nothing will ever collect. The only trace is an extra row in `fleet list`. The dashboard cannot reach this state because it deploys to a machine picked from a list; a CLI takes free text, so the check belongs here. Unknown ids are now refused with exit 6, and `--create` allows the pre-staging case explicitly. **A bad `--since` exited 1, not 2.** `guardrails` raised a bare `ValueError` where every other bad flag value in the CLI is a usage error. Now `typer.BadParameter`, so it exits 2 like `--since` everywhere else. **Three key-mode refusals read "the versioned API an key authenticates against".** Grammar, but it is the message a CI job gets, so it is the sentence that has to survive being read once at 3am. Also adds the JSON-contract tests that would have caught an earlier slip in this branch: the models emitted `vars()`, which leaked Python snake_case into a contract that is camelCase everywhere else — the kind of difference a harness finds at runtime rather than in review. `to_dict()` now fixes the shape and the test asserts no key contains an underscore. Docs: the README gains a Cloud-managed policies section leading with the full-replace semantics, and the agent skill gains a `policies · fleet · guardrails` reference — the skill matters most here, because an agent reading only `--help` would meet `--set` without meeting what it drops. The enterprise CLI doc lives in FailproofAI/agenteye and is NOT updated here. 838 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by looking at real output rather than at the code. The deploy footer said "1 policies after this change". Pluralisation, but this line is the summary of a destructive full-replace, and a line that reads as unfinished is a line an operator skims. The guardrails per-policy table inherited the shared panel's default title, which appends "newest first". That table is ranked by policy, not ordered by time, so the panel was making an ordering claim the data does not support — the same class of wrong-but-plausible text this branch has been finding elsewhere. It now carries its own `by policy · N` title. 838 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @SiddarthAA for your contribution to Failproof AI! 🙌 We'd love to discuss your PR and welcome you to our community: https://discord.befailproof.ai/ |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Hermes
No summary yet. What this changesNo component map for this revision. RoundsNo review has finished on this pull request yet. FindingsNothing raised yet.
|
Hermes
Changes requested: first deployments can overwrite another operator’s complete policy set while reporting success. The advertised 15-minute guardrail window also requests an hour of data. Containerized tests pass. What this changesflowchart LR
n0PolicyauthoringCLI["+ Policy authoring CLI"]
n1Localpolicyverifier["+ Local policy verifier"]
n2FleetdeploymentCLI["+ Fleet deployment CLI"]
n3GuardrailreportingCLI["+ Guardrail reporting CLI"]
n4EnforcementAPIclient["~ Enforcement API client"]
n5Enforcementdatamodels["~ Enforcement data models"]
n6CLIpresentation["~ CLI presentation"]
n7Operatordocumentation["~ Operator documentation"]
n0PolicyauthoringCLI -- "policy source" --> n1Localpolicyverifier
n0PolicyauthoringCLI -- "policy versions and drafts" --> n4EnforcementAPIclient
n2FleetdeploymentCLI -- "full-set deployments" --> n4EnforcementAPIclient
n3GuardrailreportingCLI -- "report windows" --> n4EnforcementAPIclient
n4EnforcementAPIclient -- "API payloads" --> n5Enforcementdatamodels
n5Enforcementdatamodels -- "rendered state" --> n6CLIpresentation
n2FleetdeploymentCLI -- "replace semantics" --> n7Operatordocumentation
Rounds
FindingsOpen
|
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
High: First-deployment races are accepted as successes
- Rule:
DATA-001 - Location:
fp-cli/fp_cli/enforcement.py:222 - Evidence:
fleet_deployrecordsbase=Nonewhen a checked-in machine has no deployment, then callscheck_raceafter its full-replacement PUT.check_racereturns immediately for everybase is Noneat fp-cli/fp_cli/enforcement.py:222. Therefore two operators can both read no deployment; after one creates generation 1, the other's stale PUT creates generation 2 and replaces the first policy set, yet the second command reports success. The accompanying test only accepts(None, 1)and does not reject(None, 2). - Required change: Treat a first deployment as clean only when the returned generation is 1, and add a regression test that
check_race(None, 2)raises. To prevent rather than merely detect overwrites, make the API enforce an expected-generation/CAS precondition on the PUT.
| here is how the CLI would become the easiest way to silently overwrite a | ||
| colleague. | ||
| """ | ||
| if base is None: |
There was a problem hiding this comment.
Hermes — High/High (DATA-001): First-deployment races are accepted as successes
fleet_deploy records base=None when a checked-in machine has no deployment, then calls check_race after its full-replacement PUT. check_race returns immediately for every base is None at fp-cli/fp_cli/enforcement.py:222. Therefore two operators can both read no deployment; after one creates generation 1, the other's stale PUT creates generation 2 and replaces the first policy set, yet the second command reports success. The accompanying test only accepts (None, 1) and does not reject (None, 2).
Required change: Treat a first deployment as clean only when the returned generation is 1, and add a regression test that check_race(None, 2) raises. To prevent rather than merely detect overwrites, make the API enforce an expected-generation/CAS precondition on the PUT.
…access
Found by driving the commands as three different users rather than one.
`fleet deploy` short-circuits a no-op before the write — desired-state
semantics, so a retrying harness re-running the same deploy succeeds instead of
erroring. That is deliberate and worth keeping. But it has two consequences that
were nowhere in the docs:
* `applied` in the JSON is the ONLY way to tell "I changed it" from "it
already matched". The exit code is 0 for both, on purpose.
* Because the short-circuit precedes the write, a user with `policies:read`
and no `policies:write` also gets 0. Nothing was written and they gained
nothing, but a harness treating exit 0 as "I have write access" would be
wrong — every deploy that actually changes something correctly exits 5 for
that user.
Verified across three permission levels: admin, `policies:read` only, and a user
with no policies permissions at all. Reads and writes gate exactly as expected
in the other seven cases; this was the one place the exit code alone does not
tell the whole story, so it is now stated in both the code and the agent skill.
838 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y file as "database error" Two more from driving the commands with hostile input rather than reasonable input. **`fleet show <typo>` exited 0 with an empty set.** Indistinguishable from a real machine that simply has nothing deployed — which is a state that genuinely exists, so the empty result looked like an answer rather than a miss. `history` and `rollback` had the same hole. All three now go through one `_require_machine` check and exit 6, matching `policies show` and the check `deploy` already had. That check also covers an id containing `/`, which is interpolated into a URL path further down and would otherwise address a different route entirely. **A binary file published as "database error".** A NUL byte in policy source reaches Postgres and returns a bare internal failure to somebody who has almost certainly pointed the command at the wrong file. The server ought to refuse it; that repository is out of scope here, so the CLI refuses first with a sentence that names the likely cause. The guard covers all five input shapes — a check on one of five paths is not a check — and ordinary unicode is explicitly not caught by it, since emoji and CJK are legitimate policy content. Both were found in a hostile-input pass alongside path traversal, 1.2 MiB sources, 200-character ids, control characters and empty files; everything else was already refused correctly by the server's own validation. Concurrency held up under real load: six simultaneous deploys to one machine produced exactly one clean write and five detected races, the generation counter advanced by exactly six with no skips, and the final policy set was intact. 841 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rounds of end-to-end testing — 4 more bugs found and fixedDriven against a live deployment as three different users, plus hostile input and real concurrency. Round 1 — permissions and multi-tenancySigned in as three real users via OTP (read from mailpit), not simulated:
Org scoping is clean: Round 2 — the agent/harness pathAll 9 read commands under Idempotency is exact — the same deploy three times: Round 3 — hostile input and concurrencyPath traversal, ids with slashes, 500-char ids, unicode/emoji, control bytes, 1.2 MiB sources, empty files, negative and huge rollback generations. Everything refused correctly except the two bugs below. Six concurrent deploys to one machine: The 4 bugs this round found
One behaviour documented rather than changedA no-op 841 tests pass (47 unit tests on the pure logic). Still not done
|
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
High: First-deployment races are accepted as successes
- Rule:
DATA-001 - Location:
fp-cli/fp_cli/enforcement.py:222 - Evidence: When a checked-in machine has no deployment,
fleet_deployrecordsbase=None(fleet_cmds.py:170-176). Two operators can both read that state; the first PUT creates generation 1 and the second stale full-replacement PUT creates generation 2.check_racereturns immediately for everybase is Noneat enforcement.py:222-223, so the second command reports success after overwriting the first set. The tests only accept(None, 1)and do not reject(None, 2). - Required change: Require a first deployment to return generation 1 and add a regression test that
check_race(None, 2)raises. To prevent the overwrite rather than detect it after the fact, add an expected-generation/CAS precondition to the API PUT.
1 advisory finding
- Medium/High
--since 15mqueries a full hour — The command advertises15m, but_hoursmaps it to1at guardrails_cmds.py:31. Summary and timeline then pass that value as the API'shoursparameter (lines 66-68; client.py:1507-1524), so a request for 15 minutes includes up to an hour of coverage and decision data. (fp-cli/fp_cli/commands/guardrails_cmds.py:31)
They are one workflow — write a policy, put it on machines, see what it blocked — and the help split them across two groups, with `guardrails` under OBSERVE and the other two at the bottom of a nine-row MANAGE. Somebody scanning for "how do I control what my agents can do" had to find three entries in two places and infer they were related. Named ENFORCE rather than POLICIES: the existing headings are verbs for what you are doing (OBSERVE, MANAGE), and POLICIES would also collide with the command sitting inside it. Placed before MANAGE so the reading order runs observe → enforce → manage, and ordered within the group the way the work flows rather than alphabetically. Presentation only. No command, flag or output changed. 841 pass, including the guard that every registered command appears in this table exactly once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e CLI
They were the only two-step flows in the CLI printing plain text where every
other destructive action uses the shared boxed shape:
disable no-force-push — machines stop enforcing it? [y/N]: y
disabled no-force-push
Both halves now go through the helpers that already existed.
`_write.confirm_destructive` renders the amber ⚠ box with the action, the target
in accent, and the consequence underneath — the same prompt `keys disable` and
`users disable` use — and declining prints the shared `nothing changed` notice
instead of falling through silently. Six new result cards replace the bare
`success()` lines, each naming what the change means rather than restating the
command: a disabled policy says machines stop enforcing it, a rollback says the
restored generation AND the new one it was minted as, a rename says the machine
id is unchanged.
A no-op deploy gets its own calm ACCENT `no change` card rather than the green
tick. Reporting "success" for a write that did not happen is how the exit-code
ambiguity documented last commit turns into a visual one too.
The result text is deliberately terse. The first version repeated the caveat and
the reversal command from the confirm box, which pushed the card onto a second
line at 100 columns — the confirm already carried both, and the result only has
to say what changed. Checked at 80, 100 and 120 columns.
Presentation only; no flag, exit code or `--json` shape changed, and `--json`
still emits pure JSON with an empty stderr on every path including the new
cancelled branches.
841 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
High: First-deployment races are reported as successful
- Rule:
DATA-001 - Location:
fp-cli/fp_cli/enforcement.py:222 - Evidence: Two
fleet deployinvocations can both read no deployment, so each plan hasbase=None. The PUT atfleet_cmds.py:216replaces the entire policy set and the second write returns generation 2, butcheck_race()returns immediately for everybase is Noneatenforcement.py:222-223. The second command therefore emitsapplied: trueafter overwriting the first command's policy set. The client PUT atclient.py:1480-1486has no expected-generation precondition. - Required change: Add a server-enforced expected-generation/CAS precondition to deployment writes and have the client send it. Also make the client-side check accept a first deployment only when the returned generation is 1, with a regression test that
check_race(None, 2)raises.
1 advisory finding
- Medium/High
--since 15mretrieves a full hour of guardrail data — The documented15moption maps to1in_hours()atguardrails_cmds.py:31; summary, timeline, and policy commands pass that value as the APIhoursparameter (for exampleguardrails_cmds.py:66-68). Consequently a request for the last 15 minutes includes up to an hour of coverage and decisions. (fp-cli/fp_cli/commands/guardrails_cmds.py:31)
…hard
It printed two unlabelled rows of blocks:
denies ▁▄▁▄▁█▁▁▄▁▁█▄▄▁█▁▁▁▄▄▄▁▁▁
total ▁▆▁▄▄▆▄▆▇▅▃▅▃▂▆▄▁▁▄▁▃█▃▁▁
No axis, no scale, no counts, no times. It showed a shape and nothing anyone
could act on — which is the whole question the command exists for.
Now one row per bucket: the time, a bar scaled to the busiest bucket in the
window, and the total / denied / instructed counts. The blocked share is drawn
in red INSIDE the total bar rather than as a second row, so "busy hour" and
"heavily-blocked hour" are distinguishable without arithmetic. Empty buckets
show an em dash rather than a zero, because "nothing happened" and "zero of
something that happened" read differently in a column of numbers.
The bucket label follows the size the server chose — a clock for hourly buckets,
a date for daily ones. Printing 09:00 against a 24-hour bucket is a chart lying
about its own resolution.
The summary keeps its sparkline: beside a headline number it is a fine accent,
and that is the job it was doing there. It was only ever wrong as the entire
output of a command.
`--json` is unchanged (the server's timeline verbatim) and still emits pure JSON
with empty stderr. An empty window still prints the one-line notice rather than
an empty box.
841 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.
High: First-deployment races are reported as successful
- Rule:
DATA-001 - Location:
fp-cli/fp_cli/enforcement.py:222 - Evidence:
fleet_deployusesbase=Nonewhen no deployment was read (fleet_cmds.py:170-176), then performs a full-replacement PUT (line 216).check_racereturns immediately for everybase is None(enforcement.py:222-223). Two operators can both read no deployment; the first write creates generation 1 and the second stale write creates generation 2, overwrites the first policy set, and emitsapplied: true. The client PUT sends no expected-generation precondition (client.py:1480-1486). - Required change: Make the API enforce an expected-generation/CAS precondition and send it from the client. Independently, accept a first deployment only when it returns generation 1 and add a regression test that
check_race(None, 2)raises.
1 advisory finding
- Medium/High
--since 15mretrieves a full hour of guardrail data — All three guardrail commands advertise15m, but_hours()maps it to1at guardrails_cmds.py:31. Summary and timeline pass that value as the APIhoursparameter (lines 66-68 and 99), so a requested 15-minute report includes an hour. (fp-cli/fp_cli/commands/guardrails_cmds.py:31)
Round 4 of 5. If the next review still finds something blocking, I will summarize what is left, withdraw this change request, and stop reviewing this pull request until someone asks me to start again.
Still open:
- F1 First-deployment races are reported as successful (
fp-cli/fp_cli/enforcement.py) — open since round 1 - F2
--since 15mretrieves a full hour of guardrail data (fp-cli/fp_cli/commands/guardrails_cmds.py) — noticed at round 2, on code that had not changed since the round before, so it never blocked
If one of these is not worth fixing, @hermes-exosphere dismiss <id> [reason] waives it for the rest of this pull request and gives the review another round.
…m a description
Closes the gap this branch opened: three commands could publish and deploy a
policy, and nothing anywhere checked it was JavaScript. This published, deployed
and reached every machine in the fleet —
echo 'this is not javascript {{{' | fp policies publish broken
— and failed at enforcement time, on the machine, where nobody is watching. The
CLI rejected a NUL byte; the server checks the id charset and a 1 MiB ceiling.
Neither parses.
**`publish` now parse-checks with `node --check` first.** Broken source is
refused with node's own line, caret and SyntaxError — node's internal frames and
version banner are stripped, since those are node talking about itself inside an
error about the user's policy. `--no-verify` skips the check, and a host without
node publishes with a warning rather than a block: node is a real dependency of
the check and deliberately not of the CLI.
**`policies test` runs a policy locally.** It executes the real file — bare
`import { deny } from "failproofai"` and all — against a context you describe,
and prints allow/deny/instruct per registered policy. The shim goes in
`node_modules/failproofai/` rather than beside the file so the bare specifier
resolves by node's ordinary lookup; an import map would have meant testing a
rewritten file and varies by node version anyway.
`--expect` is how CI asserts. A policy that correctly denies is a PASSING test,
so the decision never sets the exit code on its own — otherwise the command
would fail precisely when the policy worked.
**`policies compose` drafts one from plain English.** It prints the source and
stops: a generated policy that deploys itself is a generated policy nobody read.
`--out` saves it, `--publish` ships it, still syntax-checked first.
Two things found only by running it against the live assistant. The endpoint
takes `intent`, not `prompt` — it 400s before the model is called. And it
answers `text/event-stream`, not JSON: `delta` frames then one `done` carrying
the source, so reading it as JSON fails on the first frame. It now consumes the
stream through the client's existing SSE helper.
The composer also aborts itself at 30s (`agent/src/server.ts`), server-side, so
a long intent simply does not finish and raising `--timeout` cannot help. The
error says that rather than "the assistant closed the stream", because the
obvious remedy is the wrong one.
20 new tests, each skipping without node rather than failing: every broken-source
shape, ESM imports and top-level await accepted, the caret preserved and node's
stack dropped, a missing node reported as UNCHECKED rather than passing, the
strictest-decision rule, a policy that throws reported per-policy, and an
infinite loop timing out instead of hanging the command.
861 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I have spent 5 rounds of review on this pull request and I am still finding things to block on. At that point I am no longer the useful reviewer here, so I am standing down and leaving the decision to a person. I have withdrawn my change request, so I am no longer blocking this pull request. I have also stopped reviewing new commits on it. What I last reviewed: Still open:
None of this is a judgement that the findings above are wrong. It is a judgement that another round of me is not what will settle them. |
Hermes spent 5 rounds on this pull request without converging and has stood down. This change request is stale and should not block the merge.
…what disable does Two findings from exercising all 18 subcommands against a live server. **A disabled policy drew a full deploy plan, asked for confirmation, and then failed.** The server refuses it — correctly — but only after the CLI had shown the operator a change and a prompt implying it could happen. Every other precondition the plan depends on is checked before it is built (the machine exists, the policy exists, the ref parses); this was the one gap. `--add` and `--set` now refuse up front, naming `policies enable <id>` as the fix. **`policies disable` does considerably more than stop enforcement.** It REMOVES the policy from every deployment carrying it, reissuing each affected machine at a new generation. Verified against the live server: generation 16 held the policy, disabling minted 17 without it, and `fleet history` shows the reissue as an ordinary entry. And `enable` does NOT put it back — the machines that lost it need `fleet deploy --add` again. The help, the confirm prompt and the result card all said "machines stop enforcing it", which is true and badly incomplete: an operator disabling a policy to pause it would find their deployments rewritten and, on re-enabling, a fleet still missing it. All three now say what actually happens. That also corrects two tests. One claimed disable-then-remove was "the ordinary way to retire something" — it is not, because the removal has already happened; it is now documented as defensive cover for a state the server normally prevents. The other was renamed to describe what it actually pins: that only the refs you name are re-resolved. Round 1 ran every subcommand and every option: 35 checks, and the three that failed were all correct server behaviour caught by bad ordering in the script rather than bugs in the CLI. 867 pass (6 new). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every pre-existing command in this CLI states three things in its help: the
permission it needs, the `--json` shape it returns, and an example. Nine of the
eighteen new subcommands stated only the first — `policies enable/disable/delete`,
`fleet show/diff/history/rename/rollback` and `guardrails policies`.
That gap lands hardest on the reader this feature was built for. An agent
driving the CLI reads `--help`, not the source; without the shape it either
guesses the keys or calls the command once to find out.
Then I checked the shapes I had just written against real responses, and two
were wrong:
* the lifecycle commands return `machinesUpdated` as well, which is the count
of deployments the server rewrote — the number that makes `policies disable`
removing a policy from every machine visible instead of surprising, and the
one to check if you expected a no-op;
* `fleet rename` returns `labelOverride`, not `label`. The server keeps the
operator's label beside the machine's self-asserted one rather than
replacing it, and the field name is the only place that shows.
Round 2 also confirmed the new commands match the CLI's existing conventions
rather than inventing their own: 0/18 leak non-JSON or stderr under `--json`,
every destructive subcommand carries `--yes` like `keys disable` and `query
delete` do, and exit codes line up exactly with the pre-existing commands —
6 not-found, 2 usage, 2 key-mode, 3 unreachable.
867 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed the opposite
The previous commit stated, in four places, that `policies enable` puts a policy
back but does not redeploy it, and that machines which lost it need
`fleet deploy --add` again. That is wrong. Disable and enable are exactly
symmetric:
deploy -> gen 21 [en-test, no-secret-echo, prod-deploy-guard]
disable -> gen 22 [no-secret-echo, prod-deploy-guard] machinesUpdated=1
enable -> gen 23 [en-test, no-secret-echo, prod-deploy-guard] machinesUpdated=1
The server puts the policy back into every deployment it removed it from,
advancing each machine's generation again, and reports the same count in both
directions. `machinesUpdated` for an enable is 1, not the 0 I wrote.
The wrong version was the more damaging way round: an operator following it
would re-run `fleet deploy --add` on every affected machine after a re-enable,
minting a redundant generation per machine and re-pulling a fleet for nothing.
Caught by a lifecycle test that asserts the machine's state after every step
rather than trusting the command's own report — the docstring, the confirm text,
the result card and the skill all agreed with each other and all disagreed with
the server.
Round 3 otherwise found no regressions: all 22 pre-existing commands still
return valid JSON and exit 0, every global option (`--quiet`, `--no-color`,
`--timeout`, `--org`, `--base-url`, `--insecure`, `--token`) works on the new
commands, and `--help` renders for all 18 subcommands.
867 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… like every other group `guardrails policies` returned nothing `summary` did not already give you. Validated rather than assumed: its `--json` payload is byte-identical to `summary.summary.policies`, and its human render is the summary view minus one sparkline line. A third subcommand for a strict subset is a third thing to learn, document and keep in step. Bare `fp guardrails` also ran the summary from a callback, which made it the only group in the CLI that DID something instead of printing its help — `keys`, `query`, `users`, `settings`, `alerts`, `audits`, `issues`, `orgs`, `policies` and `fleet` all print usage and exit 2. It now does the same, so all twelve groups behave identically. That removes the group-level `--since`/`--machine` with it. They existed only to feed the callback, and having them in two places taught a shape the rest of the CLI does not have; they stay on `summary` and `timeline`, where the work is. The split that remains is the one worth keeping: `summary` answers "how are we doing" (headline stats, a deny sparkline, the per-policy table) and `timeline` answers "when did it bite" (per-bucket rows with counts). Neither is a subset of the other. Also fixes a stale hint the audit turned up: the help table still advertised `policies` as "list show publish enable disable delete", missing `test` and `compose` from two commits ago. Every group's hint is now checked against the real command tree — the only two that disagree are `audits` and `issues`, both deliberately abbreviated with a comment saying why. 867 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… lines
**`fleet rename` reported success and `fleet list` kept showing `-`.** The
server keeps two names for a machine: `label`, which the machine asserts about
itself, and `labelOverride`, which an operator sets — separate columns, and
`rename` writes the second. The model read only the first, which is null on
every machine that never reported one, so the rename was invisible everywhere
except its own success message. Precedence is now `labelOverride || label`,
mirroring `machinePicker.ts` in the dashboard, and both fields survive into
`--json` so a harness can tell which it is looking at.
**`fleet history` and `fleet diff` printed one raw line per row** while every
other list in this CLI is a panel. Both are now panels, and both gained the
column that makes them worth reading:
* history shows a `change` column — what moved between each generation and
the one below it. A reissue (the server rewriting a deployment because a
policy was disabled or re-enabled) then reads as an ordinary +/- rather than
an unexplained new row, which is exactly the thing you open history to see.
* diff leads with `N of M behind` and colours only the drifted rows, because
those are the only reason to run it.
History also uses the CLI's shared time column rather than a date. Generations
land seconds apart — twenty-one rows of `08-19` distinguished nothing, and the
shared helper already folds the date back in when rows span more than a day.
867 pass, plus 4 covering the label precedence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ployment
It printed the id, the generation and the policy list — about a third of what
the two endpoints return, and quietly implied the machine was running them.
It is not. `laptop-sidd` is told to run `prod-deploy-guard` at generation 21 and
has never collected it: `appliedDeployment` is null. That field is the answer to
the only question this view is opened for, and it was the one thing left out —
so the card was confidently wrong rather than merely sparse.
It now reads the machine record as well as the deployment, and reports:
╭─ laptop-sidd ────────────────────────────────╮
│ chutney │
│ deployment #21 · not yet collected │
│ deployed by admin@local.host · 43 min ago │
│ last seen 5 hr ago · 197 events │
│ policy ver effect │
│ prod-deploy-guard v1 enforce │
╰──────────────────────────────────────────────╯
Three states rather than a boolean: `not yet collected`, `machine is on #N`
(behind but alive), and `collected`. The operator label appears here too — the
machine is called `chutney`, which `show` previously never mentioned.
A machine with no deployment now gets the same card instead of a one-line
notice. "Checked in and given nothing" is a real state and usually the one being
looked for; the old line could not distinguish it from a dead host, where the
card shows 7 days ago and 23,314 events.
Times are relative in the card and raw in `--json`, which now returns
`{machine, deployment}` rather than the deployment alone — so a harness gets
`drifted`, `appliedDeployment`, `lastSeen` and both label fields without a
second call. Reused `_relative_age` rather than adding a second humaniser; the
machine side speaks epoch-ms, so `_epoch_age` converts and delegates.
Costs one extra request. `show` already called `/deployments`; drift is only
knowable from `/machines`, and there is no single-machine GET — that route is
DELETE-only.
871 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iscards
Two things, found by asking what the endpoint returns versus what the table drew.
**It displayed 6 of the machine record's 11 fields**, and the most useful
omission was `lastSeen`. A host that last reported seven days ago rendered
identically to one that reported a minute ago — on a fleet view, "is this thing
alive" is usually the first question, and it is a DIFFERENT question from drift:
a machine can be perfectly in sync and dead. `eventCount` came with it as the
cheap corroborating signal.
machine label pol intended applied seen events state
5ca5d9e5… - 0 — — 7d 23,314 —
build-box-03 - 2 #28 — 5h 205 drifted
laptop-sidd chutney 1 #21 — 5h 197 drifted
Ages are compact here rather than the card's "7 days ago": a table cell is not a
sentence, and this column sits beside seven others. `lastCheckIn`, `appliedAt`
and `firstSeen` are still left out — `--json` carries them, and a ten-column
table buries the three people actually scan for.
**The human path fetched the deployments and threw them away.** `render_fleet`
took them as an argument and never read one; every value comes from the machine
record. That was my own leftover from rebuilding the renderer around the
corrected model. `--json` genuinely emits them, so the call now happens only
there — verified by instrumenting the client: a human `fleet list` makes exactly
one enforcement request where it used to make two.
Also fixes truncation I introduced: `last_col="ellipsis"` was clipping `state`,
the SHORTEST column, because the long one here is the machine id. Rich sizes
that fine unaided.
`--json` is unchanged: `{machines, deployments}`, raw timestamps, computed
`drifted`. 871 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hardcoded argument
`render_policy_published` took a `deployed_to` count and the caller always
passed `1`, so every publish printed "vN is not deployed anywhere yet" whether
or not earlier versions were running across the fleet. For `demo-a` v3 the truth
was that `ci-runner-01` was carrying v1 the whole time.
It was also unreadable, which is the reported symptom: the card showed an id, a
version and a sha256 — three restatements of the command — and one sentence that
was wrong. Nothing said what had been published.
It now shows the description and the size, and computes the deployment state
instead of asserting it, from the deployments the CLI can already see:
published, not deployed — no machine runs this policy yet
fp fleet deploy <machine> --add demo-fresh
1 machine still runs an older version: ci-runner-01
fp fleet deploy <machine> --add demo-a@3
every machine carrying it is already on v3
`policies show` uses the same card, so it gained the same answer.
`--json` gains `carriers`: machine id -> the version of this policy it runs, so
a harness can tell what a publish left behind without a second call.
Costs one extra request on publish. Publishing is the moment an author decides
whether to roll a version out, and the card was previously guessing at the only
input to that decision.
871 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…back A review pass over the enforcement commands. Nothing here was a crash the tests would have caught — with one exception every finding is a command that did something defensible and then reported it wrongly, which is the failure mode this surface keeps producing. * `fleet diff <typo>` exited 0 and printed "no machines have checked in yet" over a healthy four-machine fleet. Every other machine-scoped command refuses an unknown id; this one filtered to nothing and called it a result. The machine list is already in hand, so the check costs no extra request. `guardrails --machine` had the same hole and now answers the same way. * `policies publish x logo.png` printed a Python traceback. `read_source` caught OSError, but decoding happens inside read() and raises UnicodeDecodeError — a ValueError — so it escaped through Click with internal paths in it. The NUL-byte guard written for exactly this mistake could never fire: it inspects text, and a file that fails to decode never becomes text. Covers path, @path and pipe. * `fleet history` called an enforce → observe flip "no change". The row identity was `id@version`, so a generation that changed only the effect diffed to nothing — and a version bump split into `+x` and `-x`, reading as removed-and-re-added rather than moved. Now keyed by id, comparing (version, effect), using the deploy plan's own `~` for changed. * `policies list` said "policies · 4" for three policies. The endpoint returns one row per immutable version and the docstring claimed "newest version of each". The dashboard's library counts distinct policies and captions the version total; this now matches it, sorts newest-first per policy, and says so. `policies show` picks the newest version explicitly rather than inheriting the server's ordering. * `fleet rename m ""` reported `labelled m as ` — a sentence with a hole in it. The server clears the override; the card now says that. * `policies compose --out` wrote the file after the publish that can fail, so a refused publish threw away the draft the user had just paid an assistant to write. Saved first, and the write is guarded. Exit codes: a malformed ref, `--set` with `--add`, a missing or non-text source file, and a bare `deploy` were exit 1 ("the server returned an error") for mistakes the server never saw. They are exit 2 now, which is what the documented table promises and what `--since` and `--expect` in these same commands already did. A ref that parses but names something absent stays exit 1, and an unknown machine stays 6 — a script has to be able to tell a typo from a rejected write. RefUsageError subclasses RefError so every existing call site and test keeps working. `--expect` is validated before the syntax check, so a bad flag value reports itself instead of being masked by whatever node says about the file. 12 regression tests, one per finding. 883 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section already says "before you script it" and then documents only the pinning rule and the race check. The third thing a script needs is which failures are its own: exit 2 for a malformed ref or a flag combination that cannot be acted on, 1 for a ref that parses but names nothing, 6 for an unknown machine. Also notes that `fleet diff` refuses an unknown machine rather than drawing an empty fleet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…`failproofai-sdk` (#702) * feat(fp-cli): open-source the Cloud CLI as `fp-cli`, command `fp` Moves the observability CLI out of the private AgentEye monorepo and into this repo, renamed end to end. It was PyPI `agenteye` / command `agenteye` / package `agenteye_cli`; it is now PyPI `fp-cli` / command `fp` / package `fp_cli`. The distribution and the command differ on purpose: `fp` was already taken on PyPI. This is also distinct from the `failproofai` CLI this repo already builds from bin/ + src/ — that one enforces inside the agent loop, this one reads back what the loop did. This is a HARD CUT, matching the precedent set when the collector binary was renamed: no `agenteye` alias, no retired env-var fallback, and no migration of the old config file. Scripts calling `agenteye ...` break on upgrade and users run `fp login` once. - env vars the retired namespace -> FP_* (FP_TOKEN, FP_API_KEY, FP_ORG, FP_DASHBOARD_URL, FP_JSON, FP_INSECURE, FP_HOME, FP_ANALYTICS_DISABLED, FP_CLI_DEV) - config ~/.agenteye/cli.json -> ~/.fp/cli.json (still mode 0600) - telemetry PostHog `product` tag agenteye -> fp-cli. Telemetry has been disabled since well before the rename, so nothing was flowing across the boundary and the series split costs nothing. Deliberately NOT renamed — these are a cross-component contract with the dashboard and the Rust server, neither of which is changing: - the X-AgentEye-Org and X-AgentEye-Client request headers - the ae_session cookie - the SDK/collector home dir, which still belongs to the Python SDK and the collector for their event spool Repo plumbing, all of it new — this is the first Python in the repo: - a matrixed `fp-cli` job in ci.yml (3.10 and 3.13) that tests, builds, and smoke-tests the console script from a clean install of the built wheel - publish-fp-cli.yml, a manual PyPI publish over Trusted Publishing. The trusted publisher must be configured on PyPI before the first release; the workflow header documents exactly what to enter. - a uv dependabot ecosystem, fp-cli/uv.lock in the osv-scanner gate, Python artefacts in .gitignore, and the directory registered in CONTRIBUTING.md and CLAUDE.md Also fixes four things found while verifying, three of them pre-existing: - the wheel now ships a py.typed marker it had been claiming via the `Typing :: Typed` classifier without providing - README documented `fp incidents`, renamed to `issues` long ago, and claimed the dashboard URL was required with no default (there is one). Both were about to become a public PyPI landing page. - tests/conftest.py's env clear-list omitted the insecure-TLS variable, so a developer with it exported ran the whole suite with TLS verification off - tests/test_v1_routing.py anchored the monorepo on any AGENTS.md; this repo has one at its root, so it would have resolved to a root with no server/ under it and failed for the wrong reason. It now anchors on the router file itself and skips cleanly when the monorepo is absent. New guards, because each of these could previously rot silently: - test_help_table_coverage.py — `fp help` renders a HAND-MAINTAINED table, so a registered command missing from it is invisible in help forever. Nothing checked this before. - test_readme_matches_reality.py — pins the README's commands, install instructions, default URL, exit codes and env vars to the code. - a tripwire on the click-compat package scan, which walks a path literal and would pass vacuously if that literal ever stopped resolving. 720 tests pass. Verified beyond the suite, which is entirely respx-faked: the built wheel installs into a clean venv, `fp` resolves, and against a real local HTTP server it sends X-AgentEye-Org, the ae_session cookie and x-request-id unchanged, writes only ~/.fp, leaves the old home dir untouched, returns exit codes 0/2/3/4 with the documented --json envelope, honours FP_*, ignores the retired variables, and prints the retired name nowhere. * docs(changelog): record the fp-cli open-sourcing (#702) * feat(fp-cli): re-establish the agent-skill mirror, now as skills/fp-cli The CLI's agent skill was mirrored to FailproofAI/skills as skills/agenteye-cli/ by sync-skill.yml in the private agenteye repo. That workflow is deleted along with the CLI, which would leave the published skill orphaned — still installable, still teaching the retired `agenteye` command, and synced by nothing. sync-fp-cli-skill.yml replaces it here: fp-cli/skill/ -> skills/fp-cli/, same force-push-one-branch, reuse-one-PR shape as the two surviving mirrors in the agenteye repo. Two things it needs from an admin, both documented in the workflow header: - an Actions secret SKILLS_SYNC_PAT on THIS repo. The agenteye repo has one of the same name; secrets do not cross repos, so this needs its own. - deleting the orphaned skills/agenteye-cli/ folder on FailproofAI/skills. Also fixes the skill's own invoke-resolution step 2, which told an agent to look for a `cli/` directory holding the fp_cli package. That directory is `fp-cli/` here, so the dev-build path would never have resolved. * fix(fp-cli): remove a customer identifier, restore publish authorization Findings from an adversarial review panel. Two are consequences of moving code out of a private repo that nobody would notice from the diff alone. A real customer's tenant slug and company name were in the tree — 20 occurrences across fp_cli/output.py and four test files, carried over verbatim from the private monorepo where naming a live tenant in a fixture was harmless. One of them is a source comment that ships inside the wheel. The name appears nowhere else in this repo, so publishing would have been its first public disclosure. Replaced with globex/Globex Corp, matching the acme/example.com vocabulary the rest of the suite already uses, and pinned by tests/test_no_customer_identifiers.py so it cannot return: it scans the package, the tests, the README, the CHANGELOG and the skill for a deny-list of real organisation names and for customer deployment hostnames. publish-fp-cli.yml had no branch check and no actor allowlist. The workflow it replaces (release-cli.yml, in the private repo) carried both, and they were lost in a change described as a like-for-like move. Authentication here is OIDC Trusted Publishing, so there is no token to withhold — repo write access IS publish access, and workflow_dispatch targets an arbitrary ref. One click on an unreviewed branch would have shipped it to public PyPI as an official release, and PyPI versions cannot be reused. Both guards restored. The publish path also now runs the same clean-install smoke test CI does, rather than only inspecting the zip. Also: - `uv sync` is now `uv sync --locked` in both workflows. uv.lock silently re-resolved eight dependencies during the move — certifi (which decides which CAs the CLI trusts against a self-hosted deployment) and posthog among them — inside a commit described as a move. Without --locked the committed lock is decorative, which also makes the osv-scanner gate over it dishonest. - README documented `fp audits update`; the verb is `edit`. The line was new in this migration, so it was a fresh false claim on the PyPI landing page. test_readme_matches_reality now checks one level deeper into each group's registered subcommands, which is why the group-level check missed it. - the Documentation URL pointed at a docs path that does not exist yet — that docs tree lands in a separate PR. Repointed at the page that exists today. - sync-fp-cli-skill.yml told an admin to delete skills/agenteye-cli/. The live public docs still hand that skill out by name, so deleting it first turns a documented install command into a not-found error. The instruction now spells out the required order. 724 tests pass. Every new guard was negative-controlled — deliberately violated to confirm it fails, rather than assumed to work because it is green. * fix(fp-cli): stop a stdin read draining a query, and a tripwire naming its own customer Six findings from the review bots on #702. `fp query update --sql @-` saved an empty query. `@-` is stdin, which drains on the first read, and the command read it twice — once to work out which fields changed, once to build the request body. Change detection compared the real text while the save wrote "", at exit 0 behind a green card. Read once into a local. `fp issues resolve` and `fp issues comment-delete` printed only the human stderr line when a prompt was declined. Both docstrings promise `{"cancelled": true}` under --json and the other ten write commands emit it, so a script reading stdout got an empty document at exit 0. test_no_customer_identifiers.py spelled out the real tenant slug it exists to keep out of a public wheel — in a public repo, in a file that ships in the sdist — and excluded itself from its own scan, so nothing reported it. The customer entries are SHA-256 digests now, matched over token substrings so both the slug and the longer company name built from it still trip, and a failure names the file, the line and the class of identifier, never the identifier. A planted invented name proves the matcher still matches, since an off-by-one in the substring window would otherwise turn the whole opaque deny-list into an assertion that passes by matching nothing. Our own org names stay in the clear: they are in LICENSE, SECURITY.md and package.json already, and a contributor who trips over one needs to see which it was. publish-fp-cli.yml asked for `id-token: write` and nothing else. Naming any scope sets every unnamed one to `none` rather than leaving it at the default, so checkout got a token that cannot read this repository — with a comment two lines up asserting the opposite. It also binds to a `pypi-fp-cli` environment now: every other guard there (the actor allowlist, the `main` check) lives on the ref being dispatched, so a writer could delete them on a branch and click Run, and OIDC mints a publishing token for whatever the workflow then asks for. The environment's branch rule lives in repo settings and its name in PyPI's publisher config — neither reachable from a branch, and deleting the `environment:` line fails the upload on a claim mismatch. Documented as required setup, because GitHub creates a missing environment implicitly and WITHOUT protection rules. sync-fp-cli-skill.yml wrote its PAT into $WORKDIR/.git/config via the clone URL — a token with Contents write and Pull requests write on FailproofAI/skills, left in a workspace where the next step runs validate-skills.py, fetched from that same repo. Clone and push now authenticate through `git -c http.extraheader` (before the subcommand, so it is not persisted into the new repo's config), from `env:` rather than interpolated into the script body. __tests__/ci/fp-cli-workflows.test.ts pins all four workflow invariants: the two that look redundant — `contents: read`, and the environment name matching the header a maintainer reads it off — are the two a cleanup would delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BL4iJTndrDrgtjsRavqs6f * chore: re-trigger CI after GitHub 429s in Set up job Both failing jobs died before running a step: codeload.github.com answered 429 to the runner's download of oven-sh/setup-bun (rust-quality) and google/osv-scanner-action (OSV-Scanner), through all three of the runner's own retries. Every job that got past setup passed, including both fp-cli matrix legs, the three test configs, build, test-e2e, docs and quality. Empty on purpose: nothing in 29d04e89 is implicated, and `gh run rerun` is blocked by this repo's own hook policy, so a new head SHA is the only way to ask for the two jobs again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BL4iJTndrDrgtjsRavqs6f * chore: re-trigger CI again — GitHub Actions still in a partial outage The previous trigger got 9 of 10 jobs green; `build` lost its oven-sh/setup-bun download to a 429/503 in Set up job, before running a step. GitHub has been in a partial system outage since 13:40 UTC (Actions major outage, ~50% failure rate on repository and archive content downloads), so the failing job rotates between runs. Every job has now passed on this exact tree — build and 8 others on 29d04e89, rust-quality and 8 others on 7900b015, Supply Chain on both — and `bun run build` was verified locally besides. Empty on purpose: `gh run rerun` is blocked by this repo's own hook policy, so a new head SHA is the only way to ask for the remaining job. Stacked rather than amended because the previous placeholder is already pushed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BL4iJTndrDrgtjsRavqs6f * chore: re-trigger CI (attempt 4) — one job per run still losing setup-bun to a 429 GitHub has been in a partial system outage since 13:40 UTC (Actions major outage, ~50% failure rate on repository and archive content downloads). Its shape here is consistent: all ten CI jobs fetch the same oven-sh/setup-bun archive at once, exactly one loses it to three 429s in Set up job, and which one rotates — rust-quality, then build, then quality. So each run is ~9/10, and a fully green run is a coin flip rather than a dead end. Every job has passed on this exact tree: quality/build/rust-quality each green in at least one of the three runs, everything else green in all of them, Supply Chain green on the current SHA. `bun run build` verified locally too. Empty on purpose: nothing in 29d04e89 is implicated, and `gh run rerun` — which would re-run the single failed job with no download stampede — is blocked by this repo's own hook policy, so a new head SHA is the only lever available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BL4iJTndrDrgtjsRavqs6f * feat(sdk): open-source the telemetry SDK as `failproofai-sdk` in sdk/python The other end of the pipe from fp-cli. The agent calls this to record what it did; the CLI reads that back. Moved out of the private AgentEye monorepo, where it was `python-sdk/`, distribution `agenteye`, licensed Proprietary and shipped as a private GitHub Release asset. It is now MIT + Commons Clause on public PyPI, matching fp-cli. `sdk/` is a directory rather than a flat `failproofai-sdk/` because more languages go beside `python/`, not inside it. ## The rename stops at the import name, deliberately The Python import name and the PyPI distribution name are the ONLY things that changed. `~/.agenteye/`, `AGENTEYE_HOME`, `AGENTEYE_ENVIRONMENT`, `AGENTEYE_SPOOL_TO_FAILPROOFAI`, the `.tmp`->`.jsonl` publish, every event type and every payload key are a contract with two separately-released daemons — `failproofaid` here and the older `agenteye-collector` in the private repo. Renaming any of them from the SDK's side writes events into a directory nothing watches, with no error on either side: batches pile up on disk, and an unread spool looks exactly like an idle one. This is the same call #702 made for `X-AgentEye-Org` and the `ae_session` cookie. `test_server_contract.py` freezes the literals so a later rename sweep cannot take them. ## Two real bugs found while writing the tests Batch files were named from a millisecond timestamp alone, so two batches written inside one millisecond got the same filename and the second `os.replace` silently destroyed the first — no exception, no log, no trace the events existed. It fired three ways: the atexit flush racing the flush thread (exactly when a run's last events are written), `flush_now()` from two threads, and across processes, since nothing in the name identified the writer and several agents sharing one spool root is the ordinary deployment. The stem now carries the pid and a per-process counter, which is what `fpai-collect`'s own batches already do; both daemons only ever required the `.jsonl` suffix. The cross-component spool test gated every assertion on a source path from the private agenteye repo, so all four skipped in every CI run — including three that assert nothing but this SDK's own resolution rule and need no other checkout at all. It now reads `crates/fpai-collect/src/config.rs` and `src/hooks/fp-home.ts` from THIS repo and never skips; the daemon that reads the spool finally lives next to the SDK that writes it. `FAILPROOFAI_SDK_REQUIRE_CONTRACT=1` in CI turns a moved file into a failure rather than a skip, because a guard that can degrade to a skip is not a guard. ## Tests 188 pass, up from 80. The new suites exist because every failure they catch is silent — the SDK returns None from a background thread and the caller moved on long ago: - `test_wire_format.py` freezes the serialized bytes of all 15 event types, including key ORDER, since `dedup.rs` hashes the canonical payload and a cosmetic reorder stops retried batches collapsing into silent duplicates. - `test_server_contract.py` pins the keys ingest promotes to indexed columns. `ps()` cannot tell a missing key from a wrong-typed one — both store NULL at 200 OK — so it checks types too. - `test_durability.py` covers 16-thread emission, concurrent flushes, fork, every exit path including the `os._exit` loss window (documented, not pretended away), ENOSPC/EACCES retry, and a reader that must never see a torn batch. - `test_zero_dependencies.py` makes the stdlib-only promise enforceable: the source is parsed for non-stdlib imports (including inside functions, which is where `_environment` really imports `os`), the manifest for a `dependencies` key, and CI installs the built wheel with `--no-deps`. - `test_no_customer_identifiers.py` is fp-cli's tripwire, ported. It caught a private-release URL in the README and the skill on its first run. Two suites can reach an AgentEye checkout via `FP_AGENTEYE_ROOT` to verify against the real `ingest.rs` and the older collector; both are opt-in and both pass today. ## Registration CI job matrixed across all five Python versions `requires-python` advertises — wider than fp-cli's two, because a package with no dependencies has no third-party floor quietly constraining which interpreters it is really tested on. Trusted-Publishing PyPI workflow, skill mirror, `uv` dependabot ecosystem, osv-scanner lockfile, and `__tests__/ci/failproofai-sdk-workflows.test.ts` guarding all of it — including that the two skill syncs share no force-pushed branch, which would silently overwrite each other's open PR. Needs out-of-band setup before the first publish: the PyPI pending publisher, the `pypi-failproofai-sdk` environment (GitHub creates a missing one WITHOUT protection rules), the `skill-sync-failproofai-sdk` label, and this repo's own `SKILLS_SYNC_PAT`. Each is documented in the workflow that needs it. The docs keep pointing at `skills/agenteye-python-sdk` until the first mirror PR lands on FailproofAI/skills — repointing them first would turn a documented install command into a not-found error, the same ordering fp-cli used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3e8zNeqL33PXoucfJcQ9C * fix(sdk): make the zero-dependency check work on Python 3.10 `tests/test_zero_dependencies.py` imported `tomllib` unconditionally, and that is stdlib only from 3.11. `pyproject.toml` advertises `requires-python = ">=3.10"`, so the suite failed to collect on the oldest interpreter we claim to support — caught by the matrix leg added in the same PR, which is what it is for. fp-cli tests two versions and would not have seen this. Fixed by importing `tomli` as a fallback rather than skipping the module. These are the manifest assertions that make "zero dependencies" enforceable rather than aspirational, and a check that quietly stops running on 3.10 is checked where it matters least — the 3.10 user is exactly the one with the most fragile environment. `tomli` is a TEST dependency. `[project.dependencies]` is still empty, which is the thing actually promised, and CI still installs the built wheel with `--no-deps` to prove it against the artifact. The dev-extra assertion had to loosen to allow it, so it is now an explicit allowlist carrying the reason for each entry rather than "everything must start with pytest". That is the stronger form anyway: the failure it prevents is a convenience library drifting in, and a name with no stated reason is the shape that happens in. Verified locally on all five matrix versions: 194 passed on each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3e8zNeqL33PXoucfJcQ9C * test(sdk): stop the configure() thread-safety test depending on execution order `test_configure_is_safe_to_call_from_several_threads` asserts an EXACT event count on the process-wide writer singleton, and did not drain it first. Nothing pollutes it today — the only other test that touches the singleton flushes — so this is not a live failure. It is one test away from being one, and the way it would present is an exact-count assertion failing in a test about thread safety, which sends you looking at the locking rather than at the fixture. Drains to a throwaway directory first. Verified the file passes alone, in the suite, and immediately after `test_sdk.py` (the order that would surface it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3e8zNeqL33PXoucfJcQ9C * fix(sdk): address four CodeRabbit findings — all four were real Two behaviour bugs, one weak regression test, one stale docstring. ## Tool and hook pairings shared one keyspace `_pending` namespaced its human and pause pairings (`human:`, `pause:`) but not its tool and hook ones, which keyed on the bare `tool_call_id` and `hook_id`. Those collide routinely — both are frequently the harness's own step id — and when they did, `hook_completed` consumed the `tool_use` timestamp and reported the interval between two unrelated events, while the real `tool_result` that followed got no duration at all. Two plausible numbers, no error, nothing downstream able to tell. I had pinned this with a test rather than fixing it, reasoning that changing recorded durations deserved its own change. That was wrong: nobody depends on a fabricated duration, and the non-uniformity WAS the bug — the fix makes the module consistent with the two pairings that were already namespaced. The keys never leave the process, so no wire format changes. ## An unusable flush_interval killed the writer thread `time.sleep()` runs before the flush loop's `try`, deliberately: wrapping it would turn a failing flush into a full-speed retry loop instead of a next-cycle one. The cost was that -1, NaN and inf all raised out of the thread and killed it — and a dead writer thread is this class's worst state, because `submit()` keeps accepting events, the queue keeps growing, nothing is written, and the caller learns none of it until the process exits and takes everything with it. Zero did not raise but busy-looped, pinning a core. Rejected at the boundary now, in `EventWriter.__init__`, `set_flush_interval()` and `configure()`. `configure()` validates FIRST, before `set_base_dir` — a rejected call must not leave a new base_dir applied with the old interval. ## The collision regression tests only usually reproduced the bug `test_two_batches_in_the_same_millisecond...` and its cross-process sibling did not force the two writes into one millisecond. They do on a fast machine, which is exactly the problem: on a loaded runner the clock advances between them, a timestamp-only stem produces two different names, and the test passes against the implementation it exists to reject. A frozen clock fixture makes it deterministic. Verified by reverting the stem — both now fail (4 processes collapse to 1 file); same check run for the correlation fix. The fixture has to reach the module through `sys.modules`, because `failproofai_sdk._writer` is the EventWriter singleton and shadows the submodule of the same name. ## Stale docstring `test_resolver_umbrella.py` still called `collector/src/config.rs` "the collector this repository ships". I corrected that same claim in `_resolver.py` and missed its copy here. 211 tests pass on all five matrix versions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3e8zNeqL33PXoucfJcQ9C * fix(fp-cli): two docs claims that contradict the shipped code Both from the Hermes review, both verified against the source, both user-facing — the README ships in the wheel to PyPI and the skill is executed by coding agents. **The README said telemetry is on by default. It is off.** `analytics_config.py` sets `TELEMETRY_DISABLED = True`, and has since before the rename, because the send path stalls every command ~5s when the analytics host is unreachable: the shutdown flush is bounded, the client build and first connect attempt are not. The README also claimed the opposite of that specifically — "sending is time-bounded, so it never delays a command". Now says it is disabled, why, and keeps the collection description as a review-it-in-advance section rather than deleting it, since re-enabling is one constant. **The skill stated half the credential ladder.** It said `FP_API_KEY` takes precedence over `FP_TOKEN`, which is true only between the two environment variables. `resolve_auth` checks the explicit `--token` flag *before* the ambient API key (`_context.py:93-103`), so exporting `FP_API_KEY` in CI and also passing `--token` runs as that user's saved session, with their org memberships, instead of under the scoped key you meant to audit. The skill is instructions an agent executes, so a half-stated rule is one an agent acts on. Now spells out all six rungs, and names the one that catches people. 728 fp-cli tests pass; MDX validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3e8zNeqL33PXoucfJcQ9C * fix(fp-cli): two --help examples that fail when you run them Both were found by running the CLI's own documented examples against a live deployment. Neither is caught by anything: help text is a docstring, so a wrong example compiles, ships, and passes the suite. `query create` pointed at a table that does not exist. The example read `FROM fp.events`; the queryable schema is `analytics`, so running it verbatim returns `relation "fp.events" does not exist` and exit 1. This one came from the migration: pre-move the line read `FROM agenteye.events`, and 3566bce5 rewrote the command name (right) and the ClickHouse database name (wrong) in the same sweep. `agenteye.events` is a protected form — the database is not renaming, and the collector still reads it. Exactly one occurrence; verified none remain in fp-cli/, sdk/, docs/ or skills/. The globals epilogue advertised a `-p` flag that no longer exists. `-p` was real as of 0.1.7 (see CHANGELOG) as the permission input for `keys create`, then was replaced by --permission-set / --add / --remove without the epilogue following. It renders at the bottom of every leaf command's --help, so it is the most-read wrong example in the CLI. The replacement is a form actually exercised against a live server, not a guess. The 0.1.7 CHANGELOG entry keeps saying `-p`: it is a historical record of a release where the flag did exist. Verified: both corrected examples run clean against a live deployment; suite is 728 passed / 0 skipped, including test_v1_routing.py's cross-repo route check run with FP_AGENTEYE_ROOT set (32 passed) — it skips silently by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): seven ways the SDK lost events without saying so All seven destroy or corrupt telemetry at runtime with no error reaching the caller — event.*() returned None long ago and the application moved on. Each fix has a regression test that was negative-controlled: the pre-fix behaviour was restored and the guard watched to fail. 1. ONE UNSERIALIZABLE PAYLOAD WEDGED THE SPOOL PERMANENTLY. Encoding was a single json.dumps over the whole drained batch, so one bad event took every event beside it down: _flush re-queued the batch and re-raised, _flush_loop retried the identical batch next interval, forever. Nothing emitted afterwards ever reached disk. default=str never helped — it is consulted for values, not keys, so a tuple-keyed cache or an object holding a back-reference both raise. Encoding is per-entry now: strict first (byte-identical for ordinary events), then a sanitised copy, then drop that one event. Encoding failures drop, IO failures still retry. 2. The queue was unbounded, so anything that stopped the spool draining turned a telemetry outage into an OOM kill of the host agent. Capped at 10_000, oldest-first, with a throttled warning. 3. The flush thread did not survive fork(). A prefork worker (gunicorn, celery, multiprocessing on Linux) published nothing at all. An os.register_at_fork handler restarts it and rebuilds the Event and lock, either of which can be inherited held by a thread that no longer exists. The inherited queue is discarded — those events belong to the parent, and publishing from both duplicated every buffered event. 4. Tool and hook durations correlated ACROSS sessions and agents. _pending is process-wide and human/pause pairs were already scoped; these two were not. Two sessions sharing a step id meant one reported the other's interval and the other reported none. 5. duration_ms/input_tokens/output_tokens were accepted at any type. The server reads them with pu32(), which stores NULL on a mismatch at 200 OK. Now refused at the boundary, where the caller still has a stack trace. 6. A new flush_interval did not apply to the cycle already waiting, so configure() was ignored for one full cycle of the old interval. 7. A flush racing interpreter shutdown lost the batch. Entries are drained before they are written, and _flush's emptiness check sat outside the lock — so the atexit flush saw an empty queue, returned, and the dying thread took the events, leaving at most a stray .tmp. The check moved inside _flush_lock. The atexit hook is also registered once at module scope over weak references (atexit.register(self._flush) made every writer immortal) and now logs its own exceptions instead of printing a traceback into the host agent's stderr during shutdown. Tests: 211 -> 266. Verified on the built wheel in clean containers on Python 3.10/3.11/3.12/3.13/3.14, installed with --no-deps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(fp-cli): move the session into ~/.failproofai/fpcli/cli-auth.json One product owned three top-level dotfiles: ~/.fp (this CLI), ~/.failproofai (the Enforcement CLI) and ~/.agenteye (the SDK and collector spool). This collapses the first into the second. ~/.agenteye stays where it is — it is a wire contract the collector reads, not a preference, and renaming it from this side writes events into a directory nothing watches. Resolution is FP_HOME > $FAILPROOFAI_HOME/fpcli > ~/.failproofai/fpcli. FP_HOME is used as-is because that is what it meant before, so an existing export still addresses the same directory; FAILPROOFAI_HOME names the shared root, so the subdirectory is appended. The old file is neither migrated nor deleted. A session lives 24h and one `fp login` reissues it, which is cheaper than a credential-rewriting path that runs once per machine and is never exercised again — and deleting a file the user did not ask us to touch is the only irreversible act available here. `fp` names the stale file in its not-logged-in message so the sign-out does not read as a bug. ## Registered in a layout this repo already governs ~/.failproofai is not a free directory. src/hooks/fp-home.ts declares its shape, crates/failproofaid/src/paths.rs mirrors it for the daemon, and resetHome walks it with rmSync(recursive). So the path is declared there and classified `user-typed` in HOME_CLASSES, which is what actually keeps it: resettablePaths() is a filter over that table and a migration drops only `derived` and `refetchable`. Verified by running the real resetHome(3,4) against a home holding the file — it removed two derived paths and left the credential intact. LAYOUT_VERSION is deliberately NOT bumped. Preservation comes from the classification, not the version, and that file's own rule is that the version moves when a path moves. Nothing moved; a bump would mark every existing home stale and run a reset on machines with nothing to migrate. Not added to paths.rs, following auditSessionFile: the daemon has no reason to open a human credential, and mirroring a path only Python writes would give paths.rs a row nothing there reads. fpcliDir is registered as deliberately unclassified (COVERED_BY_PARENT), because the credential is the thing to classify and a cache may sit beside it later. ## Four bugs the shared directory created, none of which existed in ~/.fp Writing next to another product's secrets is a different problem from writing into a directory we owned outright. Each of these destroys or hangs on a neighbour, and none of them is visible from either side afterwards. 1. A SYMLINK at cli-auth.json wrote through to its target. O_TRUNC follows links, so a link pointing at ../credentials.json made `fp login` truncate the Enforcement CLI's token and write the session over it. Now refused by name — and the link is left in place, because a person put it there. 2. A HARD LINK did the same and O_NOFOLLOW says nothing about it: it is not a link, it is a second name for one inode. Answered structurally by writing a temp file and renaming it into position, which swaps the directory entry and leaves the other name on the old inode. 3. A FIFO in the config position HUNG the CLI. open() on a FIFO blocks until a reader appears, so `fp login` waited with no output — a mutation run without the rename sat there ten minutes before being killed. The rename never opens the FIFO at all. 4. fpcli/ inherited the umask (0775 under a common 0002). The file was always 0600 so nothing was readable, but a group-writable directory lets anyone in the group replace it, which is a session swap. Created 0700 now. The shared parent is left to the umask when we create it and never re-permissioned when it exists — hardening what we own, not what we do not. The rename also makes the write atomic: a reader never sees a half-written credential, and racing processes end with one whole session. mkstemp rather than a pid-derived temp name, because two THREADS share a pid and collided under O_EXCL — caught by the concurrency test, not by review. ## Tests 46 new, covering the resolution order and empty/relative/trailing-slash/unicode env shapes; a populated home surviving intact; every hostile filesystem shape (home or config as a regular file, a directory, a FIFO, a symlink, a hard link, a broken symlink, read-only, untraversable); permissions created and preserved; temp-file cleanup on the failure path; 8-thread and 4-process concurrency; and that the legacy file is read by nothing and deleted by nothing. Each guard was mutation-tested rather than assumed: reverting the wipe protection, the legacy fallback, the precedence order, O_NOFOLLOW, the 0700, and the atomic write each fails exactly the tests that claim to cover it. Also removes test_v1_routing.py's third leg, which read the AgentEye server's router out of a checkout that is never present in CI. It skipped in every run, and a skip renders green — so the only automated check that CLI paths match real server routes was reporting success while verifying nothing. Removed rather than left switched off; the module docstring records that the coupling is now unguarded and surfaces as a 404 at runtime. 773 pass in fp-cli, 53 in the TS layout suite, 11 in Rust including every_mirrored_path_agrees_with_fp_home_ts. Verified live against a running deployment: reads and writes against the new path, the seeded enforcement-home artifacts byte-identical afterwards, 0600 on the file, 0700 on fpcli/, and no temp files left behind. Docs: fp-cli README, its agent skill, and the CHANGELOG. The enterprise CLI doc in FailproofAI/agenteye needs the same edit and is not in this repo. The public docs are untouched on purpose — docs/agenteye/cli.mdx is still entirely pre-rename (it teaches `agenteye login` and AGENTEYE_HOME), and that rewrite is tracked separately, blocked on #687. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(fp-cli): name the right stale session file for FP_HOME users The move changed the FILENAME as well as the directory — `cli.json` became `cli-auth.json` — so somebody who exported `FP_HOME` is logged out exactly like everyone else, with their old session sitting at `$FP_HOME/cli.json`. The stale-file notice only looked at `~/.fp/cli.json`, which those users may not have at all. Two ways that went wrong, both found by running it rather than reading it: * on a machine with no `~/.fp`, an FP_HOME user got a bare "Not logged in" with nothing connecting it to the upgrade — and FP_HOME is the documented way to relocate this config, so the group least able to shrug at an unexplained logout is the group that got no explanation; * on a machine that had both, the notice named `~/.fp/cli.json` — a file unrelated to how that invocation resolved — and told them to delete it. `legacy_config_paths()` now returns both candidates and checks the relocated one FIRST, so the file named is always the one this invocation would have read. Nothing that authenticates without the config file is touched: `--token` / `FP_TOKEN` and `--api-key` / `FP_API_KEY` never opened a file and still do not, verified against a live deployment with no config present anywhere on the machine. Read-only commands still write nothing. The blast radius of the whole move is exactly one thing — a machine whose session came from the config file needs one `fp login`. Also confirmed, since this is the last thing standing between the change and production: creating `~/.failproofai` cannot fool the Enforcement CLI's setup. `isConfigured()` reads `policies-config.json`, a specific file, not the directory's existence — so a machine where `fp login` ran first still reports unconfigured and still gets its wizard. 776 pass. The two new cases are mutation-tested: collapsing the candidate list back to the default alone fails both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(fp-cli): three help strings still printed the pre-move config path `login`, `logout` and `orgs switch` all told the user their session lives at `~/.fp/cli.json`. It does not, and all three print it — this is output, not a comment. `orgs_cmds`'s module docstring said the same. The same failure mode as the `fp.events` example fixed earlier on this branch: help text is a docstring, so a wrong path compiles, ships, and passes a green suite. Nothing in the move could have caught these, because nothing reads them. So this adds the check that would have: a test walking every shipped module for the old path. `config.py` is exempt — that is where the legacy location is deliberately named, to recognise a pre-move install and say so. Verified by planting a stale path back into `orgs_cmds` and watching it fail. 777 pass. `fp login --help` now prints the real location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(fp-cli): adopt a pre-move session instead of signing the machine out Reverses the forced re-login this branch shipped two commits ago. That was a defensible call for a young CLI and the wrong one for a release going to production: `fp login` needs an emailed code, so it cannot be scripted, and the upgrade would have interrupted every human on every machine to buy nothing but a simpler code path here. A session at the old location is now read on the next command, written to the new one, and returned. Nobody is signed out and no command changes behaviour. Three properties, each chosen against a specific way this goes wrong: * It COPIES. The old file stays exactly where it is, so an older `fp` still finds its session and a half-rolled-out fleet is not a one-way door. Moving it would make the upgrade irreversible on the machine, which is not a property to hand a release that is still `Unreleased`. * It is BEST-EFFORT. A read-only home, a full disk or a symlink we refuse leaves the session that was found still returned to the caller. Our own housekeeping must never be the reason someone is logged out. * It does NOT reach past `FP_HOME`. Somebody who exported it said where their config lives; looking in `~/.fp` anyway would adopt a session from a context they deliberately moved away from — a different tenant, or another user's leftovers on a shared box. That was also how the fallback quietly picked up the developer's own login and turned ten unrelated tests red. With this the change is genuinely non-breaking. Verified end to end against a running deployment: a home holding only a pre-move `~/.fp/cli.json` runs `fp whoami` with no login, comes back authenticated, lands the session at the new path with mode 0600, and leaves the old file intact. The credential-file paths were never the whole story and are unchanged either way: `--token` / `FP_TOKEN` and `--api-key` / `FP_API_KEY` never opened a file, so CI that authenticates by environment never enters any of this, and read-only commands still write nothing. 784 pass. Adoption is mutation-tested — deleting it fails four tests, including the unwritable-target case that exists precisely so a machine that cannot be migrated is not punished for it. Docs: CHANGELOG, README and the agent skill all said "you will be asked to log in once" and now say the opposite, because they now describe the opposite. The enterprise CLI doc lives in FailproofAI/agenteye and is updated there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(fp-cli): adoption must not resurrect a session after logout `logout` writes a config with no token rather than deleting the file, so adoption has to key off the file being ABSENT or unparseable — never off "there is no token in it". Keying off the token would make every command after a logout re-adopt `~/.fp/cli.json` and sign the user back in, which is a worse bug than the one adoption fixes. The code already had it right; nothing asserted it, so the next person to simplify that condition would have found out from a user. Two tests: through `clear_token` as a logout really goes, and the invariant stated directly with a tokenless config planted by hand. Found by driving the built wheel rather than the source — the same pass that confirmed the six-process race leaves valid JSON, mode 0600 and no temp files. 786 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): stop the test suite writing into the developer's real spool Two tests build an EventWriter with a long flush interval to inspect its queue and deliberately never flush it. But every writer registers itself in _writer._live_writers, and _flush_all_at_exit flushes ALL of them at interpreter exit — which runs after pytest has torn down its fixtures, so whatever redirection a test applied is already undone and get_base_dir() resolves to the real ~/.agenteye again. One run of the queue-cap tests deposited 162,751 synthetic events into a live spool, where a configured collector would have shipped them to a real dashboard as though an agent had emitted them. A per-test fixture cannot fix this, because the write happens after the last fixture is gone. So tests/conftest.py redirects AGENTEYE_HOME at import, straight into os.environ rather than through monkeypatch — pytest undoes monkeypatch at session end, and session end is still earlier than the flush. setdefault, so a developer already pointing at their own scratch spool keeps it; test_resolver_umbrella.py deletes the variable per test, so the resolution rules themselves are still tested against a clean environment. Both tests also take the `spool` fixture now, which makes the intent explicit rather than relying on the conftest alone. Verified: the real spool's file count is identical before and after a full suite run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sdk)!: default the spool to ~/.failproofai/custom-agents The umbrella root could always have been selected, through an AGENTEYE_SPOOL_TO_FAILPROOFAI opt-in — except that opt-in ALSO required the directory to already exist, and nothing ever created it: not the SDK, not failproofaid, not either installer. customAgentsEventsDir in fp-home.ts is exported and called from nowhere, and the daemon computes the path only to watch it. So the branch never fired once and every shipped SDK wrote to ~/.agenteye regardless of what the operator set. The feature was documented, tested and unreachable. Resolution order is now: 1. set_base_dir() explicit 2. $AGENTEYE_HOME escape hatch 3. ~/.failproofai/custom-agents default WHY THIS IS SAFE ON failproofaid: it watches BOTH roots and always has (spool_dirs in crates/fpai-collect/src/config.rs is built from custom_agents_events_dir() AND agenteye_events_dir(), both kept indefinitely). So this changes which directory the files land in and nothing else. Batches already spooled under ~/.agenteye/events are not orphaned — they stay put and are still collected; that directory simply stops growing. WHAT BREAKS: a host running the older agenteye-collector, which resolves $AGENTEYE_HOME or ~/.agenteye and nothing else (collector/src/config.rs, base_dir(), verified — it has no reference to failproofai at all). There the new default writes where it does not look, silently. That host sets AGENTEYE_HOME=~/.agenteye, which is the documented escape hatch precisely because both daemons honour it and so it cannot itself desynchronise them. demo-agent in the AgentEye repo is exactly this shape and needs the matching ENV line; that change is on the other side. AGENTEYE_SPOOL_TO_FAILPROOFAI is retired rather than kept as a no-op — anyone who exported it was asking for this and now has it. A new test asserts no module reads it, checked over os.environ lookups rather than source text: the frozen-strings guard was passing on a mention of the name in a comment while the variable itself was being deleted, which is the same vacuous-pass class the guard exists to catch. failproofai_custom_agents_dir() returns Path instead of Path | None and no longer checks existence — that check is what made the opt-in dead, since a spool root that must pre-exist can never be where a first batch is written. The writer already mkdirs what it is about to write into. Verified on the built wheel in clean containers (3.10 and 3.14): the default resolves and creates the umbrella on first write, AGENTEYE_HOME still redirects to the legacy root, and the retired variable is inert. The cross-language contract test reads the Rust and the TypeScript directly and passes with FAILPROOFAI_SDK_REQUIRE_CONTRACT=1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sdk): pin what the SDK may create inside ~/.failproofai The spool root moved into a directory the CLI and the daemon own, so "make the directory work" is no longer the whole requirement: a machine that has only ever run this SDK must be indistinguishable, to every other component, from a machine that has run nothing. detectLayout() in src/hooks/fp-config.ts is why. It reads VERSION, config.json, config.toml and layout 1's seven markers to decide whether a home is absent, current, stale or future — and a `stale` verdict is what authorises resetHome(), which deletes files. Creating any of those landmarks from here would hand the CLI a half-built home it believes it wrote. Verified against the real detectLayout(): a home holding only custom-agents/ returns {kind: "absent"} with isConfigured() false. Pinned from this side so a regression fails in the SDK's own suite rather than in the CLI's, later. The three machine states each assert the EXACT set of paths that appear, not merely that the events directory exists — that weaker assertion passes just as happily when a VERSION file appears beside it: * spool already present -> exactly one new batch file * home present, no spool -> exactly custom-agents/ + events/ + the batch * nothing present -> exactly the home + those two + the batch Plus: an existing configured home comes through byte-identical AND with mtimes unchanged (a rewritten config.json with identical content is still a component writing a file it does not own), directory modes are owner-rwx and not world-writable, an unwritable home raises and keeps the events queued rather than dropping them, and AGENTEYE_HOME still bypasses the umbrella without creating it. Negative-controlled both ways: stamping a VERSION file fails 5 of these, creating a sibling directory fails 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): four review findings — durability, u32 range, NaN, cancellation All four verified against the code before fixing, all four fixed, each negative-controlled by reverting it and watching the new guard fail. HIGH — batches were atomically published but not durably committed. write_text() + os.replace() makes visibility atomic to readers and commits nothing to the platter, so a power loss could leave a correctly-named, zero-length .jsonl. The collector reads it, POSTs it, takes the 200 and then DELETES it (remove_file in crates/fpai-collect/src/uploader.rs) — permanent, silent loss. An asymmetry more than an oversight: this repo's own Rust spool writer has called sync_all() at this exact point from the start, with the same comment. Now fsync before the rename and fsync the parent directory after it; the second half matters because the reverse failure leaves the bytes on disk under a .tmp name the watcher ignores by design. HIGH — a measured duration_ms could violate the server's u32 contract. _validate_promoted_numeric refuses a CALLER anything outside 0..2**32-1 because pu32() stores NULL for the rest at 200 OK, while the SDK's own computation was unbounded — one field, two standards, depending on who produced it. Over the range: 2**32 ms is ~49.7 days, an ordinary lifetime for a human_wait or an agent_pause. Under it: these are wall-clock readings, so an NTP step backwards yields a negative interval that round() preserves. The four inline computations are one helper now, and an out-of-range interval is OMITTED with a warning rather than clamped — a clamped 49.7 days is indistinguishable from a measurement, and the reason this is computed rather than accepted is that a reported duration is unfalsifiable. MEDIUM — non-finite floats produced invalid JSON. json.dumps writes NaN, Infinity and -Infinity by default; they are a Python extension, not JSON. It does not raise on them, so the sanitising fallback never ran and the malformed line went out looking like a success. Both encode paths use allow_nan=False now, which turns a non-finite float into an ordinary encode failure, and _sanitize maps it to null. MEDIUM — the documented tool_call() bracket caught Exception, and asyncio.CancelledError inherits from BaseException. A cancelled async tool emitted tool_use with no tool_result, orphaning the event and its correlation slot. events.md's session bracket had the same gap; run() in the same file already used BaseException, which is what makes these an inconsistency rather than a policy. The except Exception around the emit call itself is unchanged on purpose — catching BaseException there would let telemetry block a Ctrl-C. Tests 277 -> 313, including tests/test_skill_snippets.py, which parses every fenced Python block in the skill and fails a handler that wraps an emit without catching BaseException — a documented snippet is code an agent copies into a real loop, and nothing else exercises it. Verified live against the running local stack, SDK -> daemon -> DASHBOARD (/v1/events, not the server's :8080) -> ClickHouse: a payload carrying NaN, inf, a reference cycle and a tuple key arrives as {"budget": null, "confidence": null, "label": "kept"} and {"cache": {"(1, 2)": "hit"}, "g": {"name": "node", "self": "<circular reference>"}}, with duration_ms matching the real interval. Both spool roots collected: the new ~/.failproofai/custom-agents and the legacy ~/.agenteye. All four fixes re-verified on the built wheel across Python 3.10-3.14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): five bugs found by adversarial testing, two of them crashes Hunted rather than re-run: each was found by attacking a specific assumption, reproduced, fixed, and negative-controlled by reverting the fix and watching the new guard fail. 1. event.*() could raise KeyError INTO THE CALLER'S AGENT LOOP. _track_pending did len() -> next(iter()) -> del with nothing serialising the three, so two threads at a full _pending picked the same victim and the second del raised. 24 crashes per 30_000 calls across 10 threads. Only fires once the map is full — i.e. only in the long-running multi-agent process the cap exists for. Tolerant eviction now, and deliberately no lock: a lock held at a fork() is inherited locked by a thread the child does not have. 2. An exploding __repr__ re-opened the permanent spool wedge. _encode_entry caught (TypeError, ValueError, RecursionError), but default=str runs the caller's __repr__, which can raise anything. Those escaped and the batch was retried forever — the same wedge, a different exception type. Catches Exception now; never BaseException, so Ctrl-C still interrupts. 3. A non-string session_id/agent_id was dropped by the server at 200 OK ({"accepted":0,"skipped":1}, verified live). The SDK reported success and the collector deleted the batch. None is the realistic way in. Validated on all 15 methods; blank ids refused too, because those the server ACCEPTS and silently groups every event under one empty id. 4. A stuck write stranded one .tmp per flush cycle — ~170_000/day at the default interval, on the disk already in trouble, invisible because the watcher ignores them by extension. 5. A lone surrogate made the server skip the whole event. os.fsdecode and errors="surrogateescape" produce them and json.dumps escapes them happily, so nothing failed locally. Scrubbed with backslashreplace, reached via one substring scan so clean events keep the fast path. Also corrected two docs that contradicted the shipped resolver after the default moved: configure()'s docstring (the SDK's most-read) and README:46. Tests 313 -> 428. Verified live end to end against the local stack, SDK -> daemon -> DASHBOARD /v1/events -> ClickHouse: 1803 of 1804 events ingested with the one poison event dropped alone, and a payload carrying NaN, -inf, a lone surrogate, a null byte and 2**64 stored intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sdk): stop the spool-contract test aborting the suite outside the repo `REPO_ROOT = Path(__file__).resolve().parents[3]` raises IndexError on a shallower tree, and a shallower tree is precisely the packaged-sdist case that `_read_sibling` in the same file is written to handle — its docstring says "in a packaged sdist that is expected". Because it raised at IMPORT, pytest reported a collection error and stopped the entire run rather than skipping the one file that needs the repository. Reproduced by copying sdk/python somewhere on its own: 428 passing tests became `1 error`. So the graceful path was unreachable in exactly the situation it exists for. REPO_ROOT is now resolved defensively and the existing REQUIRE-driven skip/fail logic decides, as designed: 423 passed / 8 skipped outside the repository, 429 passed / 2 skipped inside it. Found by running the full suite on all five supported interpreters in clean containers, which is how the sdist layout got exercised at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(fp-cli): guard the credential path against drifting from the register ~/.failproofai/ is a governed layout. src/hooks/fp-home.ts declares it — "nothing outside this file may join a path onto the failproofai home" — and what actually keeps a reset off the CLI's session is its `user-typed` entry in HOME_CLASSES, because resettablePaths() is a FILTER OVER that table, not a list of things to keep. Nothing checked that the two sides agreed, and config.py said so itself above FPCLI_SUBDIR: "change one, change the other; nothing checks." Confirmed by experiment rather than assumed: renaming fpcliDir to "fp-cli" in the TypeScript and leaving Python untouched left 53 TS tests and 59 Python tests all passing, with the register describing a directory nothing writes and the real credential sitting at a path it had never heard of — safe only by accident, and only until somebody classifies its parent. tests/test_fp_home_contract.py reads fp-home.ts and pins the subdirectory name, the credential filename, the home directory, the FAILPROOFAI_HOME override, the `user-typed` classification, and the deliberate ABSENCE of a class on the directory itself (auditDir's rule: a user-typed parent would protect a cache added later, a derived parent would delete the session). It mirrors the SDK's test_spool_contract.py next door, including the parts that stop a source-reading test passing vacuously: every pattern must match exactly once, the anchors are asserted separately, and CI sets FP_CLI_REQUIRE_CONTRACT=1 so a moved register fails instead of skipping. The REPO_ROOT resolution is guarded too — the SDK's version raised IndexError at import on a shallower tree, which aborts a whole suite instead of skipping one file. Five negative controls, each failing the right test: rename the directory, rename the file, downgrade the class to `derived`, restructure so the regexes match nothing, and classify the directory as a whole. Verified end to end as well: a real `fp` session planted in a populated home survives a real resettablePaths() reset, while audit/cache beside it is removed. Also corrects three comments that described the behaviour before ce2012db added session adoption — fp-home.ts ("did NOT migrate… costs a login"), config.py ("Never read, never written, never deleted") and test_failproofai_home.py's own docstring ("neither read nor deleted", 200 lines above the tests asserting it IS adopted) — and fp-home.ts's citation of home-classification.test.ts, a file that has never existed. The classification guard is real and lives in __tests__/hooks/fp-home.test.ts. fp-cli 786 -> 794. SDK 429, TS fp-home 53, workflow guards 73, tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [fp-cli] fp policies, fp fleet and fp guardrails — the dashboard's enforcement pages from a terminal (#727) * feat(fp-cli): fp policies, fp fleet and fp guardrails Brings the dashboard's three cloud-managed-policy pages to the CLI, so a person or an agent can do from a terminal what previously needed a browser: write a policy, put it on machines, and see what it blocked. Three commands because they are three jobs, split the way the dashboard splits them — `/policies` authors a version, `/enforcement` decides which machines run it, `/guardrails` reports what happened. Folding them into one would merge "what we intended" with "what occurred", which is the distinction the pages exist to keep. ## The dangerous part, and what the CLI does about it `PUT /enforcement/deployments/{id}` REPLACES a machine's whole policy set. No merge, no server-side lock. The dashboard has no deploy form precisely because of this — it edits the machine's own current set, since a form that asks you to re-tick policies silently drops whatever you forget. So `fleet deploy` is a read-modify-write: it reads what the machine runs, applies `--add`/`--remove`, shows the FULL resulting set, and writes that. `--set` is the only way to drop what you did not name, and is refused alongside `--add`. Three further guards, each for a way this loses work silently: * A bare `--add` of a policy the machine already runs keeps its PINNED version rather than moving to the newest. A pin is deliberate; upgrading a fleet on a command whose author was reordering is not. * The diff shows unchanged rows. The write replaces everything, so the set on screen is the set that will exist — hiding untouched rows hides exactly the ones a mistake drops. * The generation read before the write must come back as `base + 1`. Anything else means somebody deployed in between, and a replace does not merge, so their change is already gone. The CLI refuses instead of reporting success. (`lib/enforcementFleet.ts`'s `staleness()` does the same check, after the fact; doing it before is the difference between a warning and a save.) ## Session-only, deliberately Every route here is ROOT-ONLY on the server — absent from `/v1` because `/v1` is internet-facing and these are operator writes. The commands refuse `--api-key` up front via `deny_in_key_mode` rather than translating a path that would 404, and `enforcement` is classified in `_V1_NO_EQUIVALENT` so the anti-drift test that guards that table stays honest. ## Input and output Policy source arrives as a path, `@path`, a pipe, `-`, or an interactive paste when stdin is a terminal — five shapes because that is where people keep a file they are about to publish, and refusing the clipboard means "save it first" for the most common one-off. Every command supports `--json`, in the SERVER's shape plus what the CLI computed (the deploy plan, the drift flag). Model `to_dict()` rather than `vars()`: the latter leaks Python snake_case into a contract that is camelCase everywhere else, which a harness discovers at runtime rather than in review. Tests: 42 covering the planner, the race check and source resolution — the pure logic, because that is where a wrong answer destroys a fleet's policy set. 836 pass overall. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(fp-cli): a typo'd machine id minted a machine instead of failing Three findings from driving the commands against a running deployment rather than reading them. **A deploy to an unknown machine silently succeeded.** The server accepts a deploy to ANY id — that is how a machine can be pre-staged before it ever polls — so `fp fleet deploy no-such-box --add x` returned 0 and created `no-such-box`, carrying policies nothing will ever collect. The only trace is an extra row in `fleet list`. The dashboard cannot reach this state because it deploys to a machine picked from a list; a CLI takes free text, so the check belongs here. Unknown ids are now refused with exit 6, and `--create` allows the pre-staging case explicitly. **A bad `--since` exited 1, not 2.** `guardrails` raised a bare `ValueError` where every other bad flag value in the CLI is a usage error. Now `typer.BadParameter`, so it exits 2 like `--since` everywhere else. **Three key-mode refusals read "the versioned API an key authenticates against".** Grammar, but it is the message a CI job gets, so it is the sentence that has to survive being read once at 3am. Also adds the JSON-contract tests that would have caught an earlier slip in this branch: the models emitted `vars()`, which leaked Python snake_case into a contract that is camelCase everywhere else — the kind of difference a harness finds at runtime rather than in review. `to_dict()` now fixes the shape and the test asserts no key contains an underscore. Docs: the README gains a Cloud-managed policies section leading with the full-replace semantics, and the agent skill gains a `policies · fleet · guardrails` reference — the skill matters most here, because an agent reading only `--help` would meet `--set` without meeting what it drops. The enterprise CLI doc lives in FailproofAI/agenteye and is NOT updated here. 838 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(fp-cli): two renderer strings that stated things that were not true Both found by looking at real output rather than at the code. The deploy footer said "1 policies after this change". Pluralisation, but this line is the summary of a destructive full-replace, and a line that rea…
fp policies·fp fleet·fp guardrailsBrings the dashboard's three enforcement pages —
/policies,/enforcement,/guardrails— to the CLI, so anything an operator can do by clicking, anoperator (or an agent) can do from a terminal or a CI job.
19 commits · 14 files · +3,530 · 883 tests pass.
What's here
17 subcommands under three groups, listed in
fp helpunder a new ENFORCEheading between OBSERVE and MANAGE.
fp policieslistshowpublishtestcomposeenabledisabledeletefp fleetlistshowdeploydiffhistoryrollbackrenamefp guardrailssummarytimelineEvery command takes the global
--json, and every source input accepts a path,@path, a pipe,-, or an interactive paste.The three jobs, kept separate
Publishing a policy, deploying it, and seeing what it did are three jobs done by
different people at different times — which is why the dashboard splits them
across three pages and why this is three groups rather than one.
policiesmints immutable versions. Publishing deploys nothing, andevery success path says so, because that is the single most surprising thing
in the model.
fleetputs a version on a machine.guardrailssays what actuallyhappened once it got there — coverage comes from Postgres, decision counts
from ClickHouse, and a machine can be deployed-to and silent, or reporting and
undeployed.
Two places this can destroy work, so both are pure and tested hard
PUT /enforcement/deployments/{id}is a full replace with no server-sidelock. Send
{"policies": [a]}to a machine running[a, b, c]and it nowruns
[a]— permanently, with a 200 and no warning. The dashboard never exposesthat as a form for exactly this reason.
--add/--removeread-modify-write. The CLI reads the current set,applies the delta, shows the full resulting set, and writes that. Nothing
you did not mention is disturbed.
--setis the escape hatch for thedeclarative case and the only way to drop what you do not name.
generation it read and refuses if the write does not land at exactly one
higher — mirroring
staleness()inlib/enforcementFleet.ts. It refuses andre-reads rather than reporting a success that erased somebody.
enforcement.pyholds both with no HTTP in it, so they are testable without aserver.
Policies get checked before they reach a fleet
Nothing between an author and a machine validated policy source. This publishes,
deploys, and reaches every machine:
It then fails at enforcement time, on the machine, where nobody is watching.
policies publishparse-checks withnode --checkfirst.--no-verifyskips it; a host without node publishes with a stated reason rather than a
silent skip.
policies testexecutes the real file — bareimport { deny } from "failproofai"and all — against a context you describe, and printsallow/deny/instruct per registered policy. Nothing is published, nothing
installed.
--expectturns it into a CI assertion; a correctdenyis apassing test, so the decision alone never sets the exit code.
policies composedrafts from a description via the Cloud assistant. Bydefault it prints and stops — a generated policy that deploys itself is a
generated policy nobody read.
Review round
A full pass over the surface: every command with no-args,
--help, each option,option combinations, invalid inputs, both auth modes, and state assertions after
each mutation against a live stack.
Ten findings. One was a crash; the other nine were commands stating something
false while every test passed — which is the failure mode this surface keeps
producing, and the reason the review asserted real state after each step rather
than trusting internal consistency.
fleet diff <typo>guardrails --machine <typo>@pathand stdin, in bothpublishandtest.read_sourcecaughtOSError, but decoding happens insideread()and raisesUnicodeDecodeError. The NUL-byte guard written for this exact mistake could never fire — it inspects text, and a file that fails to decode never becomes text → clean exit 2, namedfleet historyid@version, so effect-only diffs vanished and version bumps split into+xand-x→ keyed by id, compares(version, effect), uses the deploy plan's own~policies listpolicies · 3 · 4 versions, newest-first per policy, matching the dashboard's own librarypolicies showmax(version)fleet rename m ""✓ labelled m as— a sentence with a hole in it, for what is actually a clear → "cleared the label on m"policies compose --out--setwith--add, a missing file and a baredeploywere exit 1 ("the server returned an error") for mistakes the server never saw → 2, matching the documented table and what--since/--expectin these same commands already did--expect bogusRefUsageErrorsubclassesRefError, so every existing call site and all 15pytest.raises(RefError)assertions keep working. A ref that parses but namesnothing stays exit 1; an unknown machine stays 6 — a script has to be able to
tell a typo from a rejected write.
Flagged, deliberately not changed
policies test --eventignores the policy's ownmatch.events. APreToolUse-only policy still returns DENY under--event PostToolUse, wherethe daemon would never invoke it. Caveated in the docstring, and "run every
registered policy" may be intended.
--since 15mis one hour. The server takes whole hours. The render ishonest — it prints
guardrails · 1h— but the flag advertises a window itcannot deliver.
fleet listneeds ~118 columns. At 80 it degrades tolab… inte… ap….This is the shared house renderer, not new code, but a 36-char UUID currently
wins over
state.Testing
refuse API-key mode with exit 2 (these endpoints are deliberately absent
from
/v1), andpolicies testcorrectly still works there, being local-only.--add Athen--add B≡--add Bthen--add A; aplan naming one bad policy leaves no partial write; declining on a real
pty writes nothing; a repeated identical deploy is a no-op that exits 0
without writing, so a retrying harness succeeds rather than errors.
fp-cli/README.mdandfp-cli/skill/references/commands.md.Note on CI
ci.ymltriggers onpull_request: branches: [main], which filters on thebase branch. This PR targets
feat/fp-cli, so itsfp-clijob — pytest onPython 3.10 and 3.13 with
FP_CLI_REQUIRE_CONTRACT=1, a wheel build, a wheelpayload assertion and a console-script smoke test — has never fired here. It
runs the moment these commits land on
feat/fp-cli, because that re-triggers CIon #702, which does target
main. Nothing reachesmainungated; the gate justsits one level up the stack.
Not done here
The 18 customer-visible commands are still undocumented in
enterprise-docs/cli.mdand the public
docs/agenteye/cli.mdx, both of which live in the agenteyerepo. That is a separate change in a separate repo, awaiting sign-off.