feat: shell-hazard registry — teaching plane and deterministic guard (itd-103) - #156
Merged
Conversation
The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism and decides the one delegated spec-time question: guard overrides live in a dedicated committed .abcd/guard.json, independent of the rules loader kill switch. Assisted-by: Claude:claude-fable-5
Captured last session but left untracked; rides the run's first record branch per the handover note. Assisted-by: Claude:claude-fable-5
The registry matches in command position only, so it needs the candidate command split into simple commands with shell quoting honoured: a hazard named inside a quoted argument (an incident capture whose text mentions "cd scratch && rm -rf *") must never reach command position, and the cd-chain structure must stay visible across compound separators. Quoting affects tokenisation, not argument semantics, so a quoted flag still yields the same token — no bypass. String payloads inside eval or sh -c are a documented v1 gap, marked where the tokenizer stops. Assisted-by: Claude:claude-opus-5[1m]
The registry is the itd-103 core: entries drawn only from this repo's own documented never-list, each carrying a command-position pattern over shell tokens, a blocker/warn tier mirroring the record-lint severity family, a safe successor, and a plain-language why — because the refusal itself is the lesson. Check returns allow / warn / block as values only; no stdout, no exit status. Fail-open-loud on a broken guard is the hook shim's job, so core only names the failure (ErrUnparsableCommand) and lets the caller decide. Matching is command-position only, so a hazard named inside a quoted argument never fires, while quoting is not a bypass and wrapper or environment prefixes do not hide the command. The admission gate test holds every bundled entry to its proof: each known-bad fixture fires at the declared tier, every known-good fixture fires nothing (100% true-negative floor), and known-good is at least 40% of the corpus. Known-good fixture #1 is this repo's own incident capture, the command a raw-regex guard would have refused. Assisted-by: Claude:claude-opus-5[1m]
The only escape from a blocker is a committed, reviewable override, so the registry needs a per-repo file — a dedicated one rather than a rules.json domain, because the entry schema is structured and the rules kill switch must never silently disable a safety guard. The file is repo-tree content, so it is read across a trust boundary: the shared guarded read refuses a symlinked .abcd or leaf, a non-regular leaf, and anything over the size cap, and it cannot block the guard hook on a FIFO. A file that cannot be read, parsed, or validated is a typed error, never a silent fallback to the defaults or to no guard; deciding what a broken guard means for a session belongs to the hook shim, not to core. Merging is per-field so an override can retier an entry while keeping its pattern, successor, and why. AfterCD is a pointer for the same reason: a plain bool could only tighten the cd-chain requirement, never lift it. Assisted-by: Claude:claude-opus-5[1m]
An override could write a flag constraint with no usable alternative ("" or
"|"), which is unsatisfiable: the entry would stop refusing while still being
listed as active. That is the one failure mode a guard must not have, because a
guard that has quietly stopped refusing looks exactly like one with nothing to
refuse. It is now a load-time rejection, like a missing successor or why.
Assisted-by: Claude:claude-opus-5[1m]
Multi-line input was wrong in both directions, and no fixture could see it because every bundled fixture was a single line. - Heredoc bodies were tokenised as commands, so writing a document that NAMES a hazard was refused at blocker tier. That is the same class as the quoted argument the whole design turns on, one syntax over: the body is data. The tokenizer now consumes pending heredoc bodies at the newline that starts them, honouring the quoted and tab-stripping delimiter forms, and leaves a herestring as the ordinary argument it is. - A newline after a list operator started a new chain, so the flagship entry missed its own hazard in the wrapped form agents most often emit. - Shell keywords were read as the command, so a hazard inside a conditional or a loop body never reached command position. - Validation admitted patterns nothing could satisfy (a command with a slash or a space, a subcommand starting with a dash), the same silent defang the empty flag-group check exists to prevent. An unrecognised key in a per-repo override is now rejected too, rather than dropping a blocker the repo believed it had. Each class gains a fixture, so the admission gate — not a reviewer — is what holds them from here. Assisted-by: Claude:claude-opus-5[1m]
The package map is where a reader learns what each core package owns; a new capability that is absent from it is invisible until someone greps for it. Assisted-by: Claude:claude-opus-5[1m]
A here-document body starts after the command LINE ends, not after the newline that queued it. On a line ending in a list operator the command is unfinished, so consuming the body there swallowed the continuation as body text and every command on it escaped matching. Assisted-by: Claude:claude-opus-5[1m]
The entry warns on any git clean, so telling the reader to run git clean --dry-run instead offered a successor that trips the same warning. The advice now points at a command the guard is silent on. Assisted-by: Claude:claude-opus-5[1m]
Two holes in the heredoc and continuation state machine, both found in the second review pass and both silent. - `$((1<<20))` was read as a here-document whose delimiter never arrives, so every following line was swallowed as body text and matched nothing. An unquoted delimiter must now start with a letter or an underscore; a quoted one is a delimiter however exotic it looks. - Clearing the continuation flag at the newline ended the list across a blank or comment-only line, so the wrapped cd chain was missed one blank line over. Every token-producing branch already clears it when real content arrives. An override may also declare the id it is keyed by: the documented entry shape spells one out, and rejecting it would have failed a repo's whole guard config — which, under fail-open-loud, means an unguarded session. Assisted-by: Claude:claude-opus-5[1m]
itd-103 / spc-16 delivered a registry with no way to reach it. This makes it executable: two front doors onto the one core decision, a hook entry that runs it before a shell command executes, and a health line so a guard that is not running is visible from outside the session. `abcd guard check --command "<line>"` renders the decision — allow, warn, or block with the plain-language why and the safe successor — and exits 0 / 0 / 1. `abcd guard hook` reads a pre-tool-use payload from stdin and maps the same decision onto the host's protocol: the blocking status with the message on stderr for a block, exit 0 for a warn or an allow. The two front doors part company on exactly one case, deliberately. A guard that cannot be EVALUATED — an unparsable command line, a registry that will not load — is a fault for `check` (exit 2, so a script never reads silence as clearance) and fail-OPEN for `hook` (exit 0 with a loud stderr warning, so a broken guard is never the reason a session stops). The installed PreToolUse entry wraps the binary call in a shim that passes only the binary's own allow and block statuses through; every other outcome — binary absent, non-executable, crashed, killed — allows the command and emits an UNGUARDED warning. That shim is what makes fail-open-loud true for the failure Go cannot cover: the binary not running at all. It is tested by driving the committed manifest string under a shell against a fake plugin root. `abcd ahoy` gains a guard line and three diagnostic gaps reporting the three things that can independently be false — hook installed, binary reachable, registry loadable — plus a deliberately disabled registry, which is not a fault but must not read as protection either. The hook manifest decoder is factored out of verifyHookManifest so the prompt-router check and the guard-health check cannot disagree about what a well-formed manifest is. Assisted-by: Claude:claude-opus-5[1m]
Adds the plugin command doc teaching both sub-verbs, the brief's surface contract and registry row (the record-lint surface_coverage rule requires one), the package-map note that eval and sh -c payloads are a stated limit rather than an implicit gap, and the CHANGELOG entry for the user-facing addition. Assisted-by: Claude:claude-opus-5[1m]
`abcd guard check --command ""` fell through to the stdin channel, because the channel was picked on the flag's VALUE rather than on whether it was given. In a terminal that is a silent hang; with something on stdin it answers a question nobody asked. The flag being present now decides the channel, and an empty value is the usage fault it is. Also records the three wiring decisions a future session would otherwise re-litigate: why `guard hook` sits under the user-facing verb rather than the hidden hook subtree, why the two front doors disagree on an unevaluatable guard, and why the fail-open shim is shell rather than Go. Assisted-by: Claude:claude-opus-5[1m]
Three findings from review, all on the same theme — a safety report that is wrong or unheard is worse than none. **ahoy accused an install it never inspected.** With an unresolvable plugin root the manifest is never opened and the binary is never looked for, but HookInstalled and BinaryReachable kept their zero values, so the status line read `NOT ARMED — hook not installed, binary unreachable` while the JSON in the same run said `plugin root not resolvable`. Every dev build running from source hit it, including the fallback `commands/abcd/guard.md` itself prescribes. The state is now carried explicitly: no wiring gaps are emitted, and the line reports what is unknown. The registry half stays answerable — it is read from the repo, not the plugin. **The fail-open warning was unheard.** A pre-tool-use hook that exits 0 has its stderr discarded, so every non-decision path warned into a void — the "silently absent" state the contract says cannot happen. The adapter now exits 1: still non-blocking, but a status the host surfaces. The shim passes 1 through alongside 0 and 2, so a binary that ran and reported is no longer described as having failed to run. **The documented invocation could execute the command it was asked to check.** `abcd guard check --command "<candidate>"` is expanded by the shell before the verb starts, so a candidate containing a command substitution runs at check time — the one moment the check exists to prevent — and an embedded quote breaks the quoting outright. The plugin command now passes the candidate on stdin in a quoted-delimiter heredoc; `--command` stays for literals a human typed, and the reference says why. Assisted-by: Claude:claude-opus-5[1m]
…coverage Security review, BLOCK verdict. Two ways to get an unearned clearance in silence, and one claim the code does not support. **A switched-off registry ran in silence.** `disabled: true` allows everything, and the adapter's allow branch returned nil — so the one unguarded state that needs no broken install, just a single file write the guard itself permits, was also the only one that said nothing. It now warns like every other unguarded state, naming the file that turned it off. Off can no longer pass for clear. **An oversized candidate was answered on its prefix.** `guard check` read the stdin cap and discarded the overflow undetected, so padding a command past 1 MiB returned `allow` on the part that fitted — via the stdin channel the plugin command now mandates. It reads one byte past the cap and refuses (exit 2): a candidate that does not fit is a candidate that was not checked. **"eval or sh -c" was written as if it were the whole gap.** It is not: a wrapper outside the small known set (`xargs`, `timeout`, `exec`), a backtick substitution, or simply a dangerous form no entry describes all pass too. The docs now say what an allow means — no entry matched, never "safe" — and enumerate the routes as examples rather than a boundary. The claim that disabling is "a committed, reviewable act" is likewise corrected rather than repeated: `guard.Load` reads the working tree, so an edit takes effect before review. What is true — the file is the only route, so the change lands in a diff, and the resulting state is loud — is what the docs now say. Enforcing the committed half is core-side and tracked as iss-147; the registry coverage gaps the review surfaced are iss-148. Assisted-by: Claude:claude-opus-5[1m]
…claims Follow-ups from the security re-review (verdict APPROVE, none of these blocking). **`guard check` answered `allow` from a disabled registry.** The hook path warns, but the scriptable verb rendered a bare allow, exit 0, indistinguishable from a real clearance — so a CI gate could be waved through by an edit to `.abcd/guard.json`. A disabled registry evaluated nothing, which is this verb's own definition of a fault: it exits 2 and names the file. Three claims in text this change ships were wrong: - The brief documented the fail-open path as exit 0 and said only allow and block propagate. Both backwards — exit 1 is what keeps the warning visible, and the shim passes it through precisely so a binary that ran and reported is not described as having failed to run. - The corrected coverage list named `sudo`/`env`/`time` as known wrappers, implying hazards launched through them are seen. Only the wrapper NAME is stepped over, so one flag defangs an entry the registry does describe: `sudo <hazard>` is seen, `sudo -u bob <hazard>` is not. Stated everywhere the list appears, and added to iss-148 as the sharpest of its items. - Two comments in new code still called the kill switch committed, the claim the docs had just retired. Assisted-by: Claude:claude-opus-5[1m]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements itd-103 (shell-hazard registry) per spc-16 — the spec derived from the 2026-07-27 Grill Settlements. One registry, two planes: a teaching plane and a deterministic guard any host hook can call.
What this delivers
internal/core/guard/— shell-token-aware matcher (command position only, cd-chain structure across compound separators; quoted strings never fire), bundled registry seeded strictly from the repo's documented never-list (6 entries: rm-rf-after-cd-chain, git-push-force, git-commit-no-verify, git-push-no-verify as blockers; git-reset-hard, git-clean as warns), and.abcd/guard.jsonper-repo override loading. Transport-agnostic; stdlib only.abcd guard check(CLI),abcd guard hook(host-hook adapter), a shell shim registered inhooks/hooks.json(shell deliberately: the failure it guards against is the binary not running), an ahoy guard-health status line,commands/abcd/guard.md, brief surface page + surface.json row, generated CLI reference, CHANGELOG entry.Settlements honoured
guard hookfails open at exit 1 (exit 0 would have its stderr discarded), ahoy reports guard health, and a disabled registry is announced, never silent.rm -rf *— the false-positive class that motivated the settlement.Reviews
$(…)at check time).Known gaps, recorded not hidden
guard.Loadreads.abcd/guard.jsonfrom the working tree, so the committed-override rule is not yet enforced core-side; both front doors mitigate loudly (UNGUARDED warning / exit-2 fault / ahoy OFF).xargs/timeout/execwrappers, wrapper flags such assudo -u).eval/sh -cstring payloads are a documented v1 gap per the acceptance criteria.Gates:
make preflight, docs lint and record-lint all clean (0 blockers).