Skip to content

feat: shell-hazard registry — teaching plane and deterministic guard (itd-103) - #156

Merged
REPPL merged 17 commits into
mainfrom
feat/itd-103-shell-hazard-registry
Jul 28, 2026
Merged

feat: shell-hazard registry — teaching plane and deterministic guard (itd-103)#156
REPPL merged 17 commits into
mainfrom
feat/itd-103-shell-hazard-registry

Conversation

@REPPL

@REPPL REPPL commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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.json per-repo override loading. Transport-agnostic; stdlib only.
  • Surfaces: abcd guard check (CLI), abcd guard hook (host-hook adapter), a shell shim registered in hooks/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

  • Fail-open-loud: a guard that cannot evaluate never bricks the session and never no-ops silently — guard hook fails open at exit 1 (exit 0 would have its stderr discarded), ahoy reports guard health, and a disabled registry is announced, never silent.
  • Tiers mirror the docs-lint family: blocker refuses with the safe successor as the message; warn passes with the warning injected. No in-session override.
  • Token-aware matching: known-good fixture feat: harness-name prevention gate + name-banlist intent (itd-74) #1 is this repo's own incident-capture command quoting rm -rf * — the false-positive class that motivated the settlement.
  • Admission gate: 25 known-bad / 25 known-good fixtures (50% known-good, floor 40%), 100% true-negative rate enforced by a test over the embedded corpus; a mutation run confirmed the gate bites.

Reviews

  • Ruthless review ran on both the core and the wiring diffs; all FIX-FIRST findings fixed and pinned by tests (heredoc handling, arithmetic-shift false heredoc, blank-line chain continuation, health-line overclaim, a command-doc pattern that would have executed $(…) at check time).
  • Security review (trust boundary: command parsing): initial BLOCK — silent kill-switch disarm, oversized-stdin truncate-then-allow — closed and re-verified by executing the binary; final verdict APPROVE.

Known gaps, recorded not hidden

  • iss-147: guard.Load reads .abcd/guard.json from 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).
  • iss-148: matcher coverage gaps (refspec force-push, xargs/timeout/exec wrappers, wrapper flags such as sudo -u).
  • eval / sh -c string payloads are a documented v1 gap per the acceptance criteria.

Gates: make preflight, docs lint and record-lint all clean (0 blockers).

REPPL added 17 commits July 27, 2026 19:49
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]
@REPPL
REPPL merged commit 49cffa0 into main Jul 28, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-103-shell-hazard-registry branch July 28, 2026 07:21
@REPPL REPPL mentioned this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant