[ef-32] refactor: rename CLI flags, config fields, and config filename - #28
Conversation
…e for clarity - --custom-hooks → --custom (install), --remove-custom-hooks → --custom (remove) - --beta-only → --beta for --remove-policies - customHooksPath → customPoliciesPath throughout source, tests, and docs - hooks-config.json → policies-config.json (and hooks-config.local.json → policies-config.local.json) - Add --help / -h command with full usage output - Unknown flag error now hints to run --help Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (26)
📝 WalkthroughWalkthroughThe PR systematically renames configuration and CLI terminology from "hooks" to "policies" across the codebase. This includes updating configuration file paths from Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
…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>
…forcement 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 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> * docs(fp-cli): a no-op deploy exits 0, and that is not proof of write 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> * fix(fp-cli): a typo'd machine read as "nothing deployed", and a binary 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> * feat(fp-cli): group policies, fleet and guardrails under ENFORCE 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> * feat(fp-cli): the new commands confirm and report like the rest of the 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> * feat(fp-cli): guardrails timeline says when enforcement bit, and how 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> * feat(fp-cli): validate policies, run them locally, and draft them from 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> * fix(fp-cli): refuse a disabled policy before drawing a plan, and say 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> * docs(fp-cli): nine subcommands did not document their --json shape 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> * fix(fp-cli): enable RESTORES a policy to its deployments — I documented 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> * refactor(fp-cli): guardrails is summary and timeline, and a container 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> * fix(fp-cli): rename appeared to do nothing, and two views printed raw 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> * feat(fp-cli): fleet show says whether the machine actually has the deployment 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> * feat(fp-cli): fleet list shows liveness, and stops fetching what it discards 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> * fix(fp-cli): the publish card claimed "not deployed anywhere" from a 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> * fix(fp-cli): six commands that described the wrong thing, and a traceback 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> * docs(fp-cli): the deploy exit codes a script has to branch on 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> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
--custom-hooks→--customfor--install-policies;--remove-custom-hooks→--custommoved to--remove-policies--beta-only→--betafor--remove-policies(consistent with install)customHooksPath→customPoliciesPathacross all source, tests, and docshooks-config.json→policies-config.json(andhooks-config.local.json→policies-config.local.json) in all path references--help/-hcommand added with full usage outputRun \failproofai --help` for usage details.`Test plan
failproofai --helpprints full usage and exits 0failproofai -hsamefailproofai --install-policies --custom ./my-policies.jssetscustomPoliciesPathinpolicies-config.jsonfailproofai --remove-policies --customclearscustomPoliciesPathfailproofai --remove-policies --betaremoves only beta policies--foo) prints did-you-mean + help hint🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
--help/-hcommand to display CLI usage and examples.Breaking Changes
hooks-config.json→policies-config.jsoncustomHooksPath→customPoliciesPath--custom-hooks→--custom(for--install-policies)--remove-custom-hooks→--custom(for--remove-policies)Documentation