Development record
abcd-teaches-repo-agents-the-shell-commands-they-must-never
Summary
spc-16 delivers the shell-hazard registry for itd-103: one bundled registry of
dangerous command patterns, surfaced through two planes. The teaching plane
injects the matched safety rules through the existing rules loader before
shell-heavy work; the guard plane is a deterministic core verb
(abcd guard check) that a harness hook calls at execution time to refuse a
matching command and reply with its safe successor. The design decisions below
were settled by the 2026-07-27 grill (recorded in the intent's Grill
Settlements); this spec records them together with the mechanism — it does not
reopen them.
Settled constraints (from the grill)
- Fail-open-loud. If the guard hook cannot execute the abcd binary, the
session is never blocked and never silently unguarded: the hook emits an
unmissable in-session warning, and
abcd ahoystatus reports guard health. - Two tiers, mirroring the docs-lint family.
blockerrefuses the command with the safe successor as the block message;warnlets the command pass with the warning injected. Entry shape follows the lintbanned_tokensfamily: id, pattern, tier, successor, plain-language why. - No in-session override. The only escape is a committed, reviewable per-repo config override.
- Shell-token-aware matching in command position only. A raw-regex guard
would have blocked this repository's own incident-capture command (a ledger
capture whose quoted text mentions
rm -rf *) — the known-good corpus is not optional.
Mechanism
Registry
Bundled defaults are embedded in the binary, like the rules-loader default domains. Each entry declares:
id— stable slug (e.g.rm-rf-after-cd-chain).pattern— a command-position match expressed over shell tokens, not raw text (see Matching).tier—blockerorwarn.successor— the safe form the agent is told to run instead.why— one plain-language sentence a non-expert facilitator understands.fixtures— known-bad and known-good command lines proving the entry's behaviour (see Admission gate).
Config home (spec-time decision)
Per-repo overrides live in a dedicated committed file, .abcd/guard.json
(schema_version, disabled, entries keyed by id for per-field override or
addition) — not in a new .abcd/rules.json domain. Two reasons: the guard
entry schema is structured (tier/successor/fixtures) where rules-domain
entries are prose strings, and the rules loader's kill switch must not
silently disable a safety guard — the two features keep independent switches.
Disabling the guard is itself a committed, reviewable act: "disabled": true
in .abcd/guard.json.
Two planes, one registry
- Teaching plane (all hosts). The rules loader gains a guard-backed safety domain: prompts that recall-match shell-heavy work get the matched registry entries rendered as rules (pattern, why, safe successor) injected before the agent acts. Hosts without hook support get this plane at minimum.
- Guard plane (hosts with hooks).
abcd guard checkis a core verb behind the transport-agnostic boundary: core takes a candidate command string and returns a decision (allow / warn+message / block+successor+why); the CLI front door formats it, and the host-hook adapter parses the host's hook payload into that call and maps the decision onto the host's block/allow protocol. Hook wiring is installed by ahoy for hosts that support it. - Both planes are wired at delivery: the verb is reachable from the CLI and
the plugin markdown surface (
commands/abcd/guard.md), and the hook executes it in a live session — no dead scaffolding.
Matching
Matching is shell-token-aware and applies in command position only:
- The candidate line is tokenised with shell quoting honoured; a hazard pattern inside a quoted string argument never fires (AC 3 — the incident-capture command is known-good fixture #1).
- Command position is tracked across compound separators (
&&,;,||, pipes), socd scratch && rm -rf *matches the cd-chain structure the registry entry describes. - String payloads inside
evalorsh -c '...'are a documented v1 gap, stated in the verb's reference doc and the registry README — not a silent one.
Fail-open-loud and health
The installed hook is a thin shim: if the abcd binary is missing, fails to
execute, or exits abnormally, the shim exits with the host's allow status
while emitting an unmissable warning into the session transcript. abcd ahoy
status gains a guard-health line (hook installed, binary reachable, registry
loadable) so a broken guard is visible outside the session too — never a
silent no-op, never a bricked session.
Admission gate (bundled defaults)
A registry entry is admitted to the bundled defaults only when:
- it ships known-bad and known-good fixtures, with known-good at least 40% of the entry's corpus;
- it clears the declared true-negative-rate floor: 100% on its known-good corpus — a single known-good fixture that fires blocks admission. The floor is enforced by a test over the embedded corpus, so an entry cannot ship without its proof.
The registry grows from reality: abcd capture is the single move a
facilitator needs, and recurring captures are promoted into the bundled
defaults through this gate.
Acceptance-criteria mapping
- AC 1 (fail open loud, health in ahoy) → Fail-open-loud and health.
- AC 2 (blocker refuses with successor, warn passes, committed override only) → Registry tiers + Config home.
- AC 3 (quoted-string non-firing, command position, cd-chain) → Matching.
- AC 4 (fixture corpus, ≥40% known-good, TNR floor, documented eval gap) → Admission gate + Matching.
Out of scope
- Non-shell tool calls (a later guard-family question, per the intent).
- Parsing string payloads inside
eval/shell -c(documented v1 gap). - Any scheduled or CI-side execution of the guard.
Frontmatter
| id | spc-16 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-16-abcd-teaches-repo-agents-the-shell.md
open on GitHub ↗ · commit history ↗
References1
- itd-103**abcd teaches repo agents the shell commands they must never run — and blocks them when they try.** An agent that runs `cd scratch && rm -rf *` is one failed `cd` away from deleting the working tree, and the facilitator watching the session usually has no way to know that. abcd ships a hazard registry: each entry a dangerous command pattern, its severity, the safe successor, and a plain-language why. One registry, two planes. The rules loader injects the matched safety rules before shell-heavy work, so the agent is taught the safe form up front; a deterministic guard — a core verb any harness hook can call — refuses a matching command at execution time and replies with the safe successor, so the block itself is the lesson. Hosts without hook support still get the teaching plane; hosts with hooks get both, wired in at install. The registry grows from reality: a facilitator who sees something scary needs to know exactly one move — capture it — and recurring captures promote patterns into the bundled defaults, each entry shipping with a fixture that proves its guard fires. "I couldn't have told you why that command was dangerous," said Nia, a facilitator. "I didn't have to. The guard refused it, told the agent what to run instead, and told me why in words I understood. The one time something new scared me, I captured it and moved on."implements
mentions13
- adr-42The guard's parse layer is a mistake filter, not a security boundary — matching is two-tier, and completeness is abandoned as a goal
- dcn-26072700000000062026-07-27: itd-101 spec minted as spc-16 collided with unmerged #156 (iss-80 class, both spc-N and iss-N allocators); renumbered to spc-17 by hand, duplicate capture folded into iss-80 — ids on this branch deliberately skip 16.
- dcn-26072700000000102026-07-27: itd-103 guard overrides live in dedicated committed .abcd/guard.json, not a rules.json domain — structured entry schema, and the rules kill switch must not silently disable a safety guard (spc-16).
- dcn-26072800000000042026-07-28: itd-104 spec minted as spc-16 (third live iss-80 instance this run: 16 collided with #156, 17 with the itd-101 branch); renumbered to spc-18 by hand.
- dcn-26072800000000052026-07-28: itd-102 spec minted as spc-16 (fourth live iss-80 instance this run); renumbered to spc-19 by hand.
- dcn-26081800000000012026-08-18 — The guard's parse layer is a MISTAKE FILTER, not a security boundary
- dcn-26092900000000142026-09-29 — Ruling J10 of the product thinker, applied (lane teachPlane, autonomous run A; Refs: iss-151): itd-103's teaching plane is built rather than dropped from the intent (the ruling, verbatim: "BUILD IT (a lane; the registry is the single source)"). The rules loader bundles a `SHELL` domain generated from the bundled hazard registry the guard reads: one rule per entry and one recall term per command head, plus a fixed five-word list for shell work in general (`bash`, `command line`, `force push`, `shell`, `zsh`); no second copy is written in the bundled `rules.json`, and a test fails the build if the domain and the registry part. Two consequences taken by the lane rather than the ruling: the domain is built from the bundled registry only, never from a repo's `.abcd/guard.json`, because the two features keep independent switches (spc-16, "Config home"); and `SHELL` joins `COMMITTING`, `LOAD` and `PII` as a guardrail domain, so an override that withholds a bundled lesson is named on every load.
- dcn-26093000000000082026-09-30 — Ruling CK1 of the product thinker (2026-09-29), applied (lane teachRepoGuard, autonomous run A; Refs: iss-2609300756163382): the `SHELL` domain teaches a repository's own guard entries, generated the same way as the bundled registry (the ruling, verbatim: "YES, generated the same way as the bundled registry"). This replaces the consequence the entry for ruling J10 above recorded, that the domain is built from the bundled registry only. Every rules load rebuilds `SHELL`, before any `rules.json` layer lands on it, from the registry `abcd guard` enforces in the repository (`guard.LoadRepo`: the bundled entries merged with `.abcd/guard.json`), through the same generator. Taken by the lane rather than the ruling: a lesson whose words are the repository's (an entry the file adds, or a bundled entry whose tier, pattern, why or successor it changes) carries `(repo)` after its entry id, so provenance stays visible while the domain itself stays bundled to every other contract; a guard file the guard refuses is refused here too, `SHELL` teaching the registry the guard falls back to and a load note naming the file and the reason on stderr from `abcd rules` and the hook, rather than failing the whole rule set (which would silence `PII` and `COMMITTING` over one broken guard file); the guard file's `disabled` switch does not silence the teaching, which keeps its own switches in `rules.json` (spc-16, "Config home").
- iss-80Record id allocators (itd-N, spc-N, iss-N) are branch-local: parallel agents on separate branches each scan for max+1 and mint the SAME id. Two intents both claimed itd-82 and both merged to main (PRs #46, #47). The iss-N allocator hit the same class before (iss-77 collision, manually renumbered to iss-79; class recorded as iss-74). Resolving a collision forces a renumber, which breaks the record's stated 'ids are capture-stable, never renumbered' invariant -- so the minting scheme, not the renumber, is the defect. This capture is the MINTING half: a collision-free allocation scheme is needed (forge-minted / random-suffix / timestamp / reserve-registry -- SOTA under research). The DETECTION half is armed separately: intent_lifecycle now blocks duplicate intent ids.
- iss-147guard.Load reads .abcd/guard.json from the WORKING TREE, so a disabled:true (or a retiered blocker) takes effect on the very next command, before anyone reviews it — spc-16 and the shipped docs both say the only escape is a committed, reviewable override, and nothing enforces the committed half. Reachable in one move: an agent writes .abcd/guard.json and the guard itself allows that write. Mitigated at the front door in itd-103 wiring (a disabled registry now warns UNGUARDED on every command, and abcd ahoy reports OFF) but not enforced. Proper fix is core-side: refuse a disabled:true that is not in HEAD, or drop the committed claim from spc-16. Found by the security reviewer on the itd-103 wiring branch.
- iss-151itd-103 shipped only ONE of its two promised planes: the execution-time guard plane is fully wired (abcd guard check/hook, ahoy health), but the TEACHING plane — the rules loader injecting matched shell-safety rules before shell-heavy work, per spc-16 'Two planes, one registry' — is not built; there is no guard/safety/hazard domain in internal/core/rules/. All four itd-103 ACs concern the guard plane only, so the fidelity review is 3 MET/1 MWC, but the intent headline ('two planes') is only half delivered. Wire the teaching plane as a rules-loader safety domain sourced from the same bundled hazard registry, or amend the intent's scope.
- iss-349terminology.md lists the pre-tool-use shell-hazard guard among deterministic fail-closed gates, asserting the opposite safety property from adr-42's fail-open-loud mistake filter on the one page written for an evaluator
- iss-2609012040003576GHSA-5wx3-2c86-fjpx (draft advisory, severity medium; CWE-636, Not Failing Securely): the wired PreToolUse guard hook fails open on two tokenizer error states that every shell an agent runs under executes. `tokenize` (internal/core/guard/tokenize.go) returns ErrUnparsableCommand for a backslash as the last byte of the line and for a here-document body that never reaches its delimiter line, and `newGuardHookCommand` (internal/surface/cli/guard.go) maps every Check error to exit 1 'NOT CHECKED … runs UNGUARDED', so `git push --force origin main \` and `git push --force origin main <<EOF` followed by a newline run without a verdict — bash 3.2 and 5.3, zsh and dash all execute both (the trailing backslash is dropped by bash 3.2/zsh, kept as a literal word by bash 5.3/dash; the empty heredoc body is recovered silently). A third shape shares the root cause: spaced arithmetic `echo $(( x << y ))` on one line and a hazard on the next is misread as a heredoc, because the `<<` branch tests only the byte immediately after the delimiter word for a paren and `followedByParen` sees the space, so the hazard line is swallowed as body and the same error returns. The sibling `shellInspect` (internal/core/guard/payload.go) maps the inner tokenizer's error to a warn, so `sh -c 'git push --force origin main \'` is a loud-warn allow through the same states. The fix must give the two bash-executable states a VERDICT in core rather than an error: arm 1 parses a trailing backslash as bash 3.2 does (dropped — the reading under which the hazard runs, so the fail-safe one) and yields a precise Tier-1 verdict; arm 2 turns an unterminated heredoc body into a segment flag that Check folds into a synthetic fail-closed block (the 2dbb0ca2 brace-group precedent: an error would route to the hook's fail-open), never a remap of the hook's error path, because the unterminated-quote class no shell runs must stay the loud fail-open of spc-16 / iss-269; prerequisite in the same change, the `<<` branch skips blanks before the paren test so `$(( x << y ))` classifies as arithmetic. The hook then blocks both spellings with the entry that names them, `echo hello \` allows, `rm -rf "unterminated` still fails open loud, and the repo corpus holds.