Development record
shell-hazard incident, first corpus entry: in another locally managed repo, an agent ran mkdir -p under a scratch dir, then cd, then rm -rf * in one && chain. The && made it accidentally safe (rm only after a successful cd); with separate statements or a ; separator, a failed cd would have left the harness's persistent cwd at the repo root and rm -rf * would have deleted the working tree. Failure class: destruction chained after cd + cwd-relative glob, one character between harmless and catastrophic. Safe successor: destructive commands name an absolute path and never rely on cwd or a glob. This incident and the agent's own post-mortem are the first acceptance-corpus entry for the shell-hazard registry (teach + enforce planes); guard fixture must prove the block fires on exactly this shape.
shell-hazard incident, first corpus entry: in another locally managed repo, an agent ran mkdir -p under a scratch dir, then cd, then rm -rf * in one && chain. The && made it accidentally safe (rm only after a successful cd); with separate statements or a ; separator, a failed cd would have left the harness's persistent cwd at the repo root and rm -rf * would have deleted the working tree. Failure class: destruction chained after cd + cwd-relative glob, one character between harmless and catastrophic. Safe successor: destructive commands name an absolute path and never rely on cwd or a glob. This incident and the agent's own post-mortem are the first acceptance-corpus entry for the shell-hazard registry (teach + enforce planes); guard fixture must prove the block fires on exactly this shape.
Frontmatter
| id | iss-144 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-144-shell-hazard-cd-chain-rm-glob.md
open on GitHub ↗ · commit history ↗
mentions2
- dcn-26080100000000022026-08-01 — guard round (iss-148, iss-159, iss-144): the matcher's wrapper walk now steps over a wrapper's OWN arguments rather than its name alone, off two explicit tables — the flags each wrapper documents as taking a value, and the mandatory operand no flag stepping can reach (`timeout [OPTIONS] DURATION COMMAND...`, where `timeout 30 rm -rf /` read as a command called `30`). That was the sharpest of the three items and the reason to take it first: `sudo -u bob <hazard>` read `-u` as the command name, so one extra token turned an entry the registry DOES describe into an allow, and a guard that has stopped refusing looks identical to one with nothing to refuse. The complementary miss is deliberate and now stated rather than implied — a value flag the table does not name (a bundled `sudo -Hu bob`) is not stepped over, and a miss is a non-match, never a false block. `xargs`, `timeout` and `exec` joined the wrapper set. Backtick substitution reaches command position through the same segment flush the grouping parens already gave `$( … )` by accident, scoped to parity with today's behaviour (unquoted only) rather than POSIX completeness, so the two spellings of one substitution stop disagreeing. Registry content took four additive Pattern fields, each optional and per-repo overridable like the rest: `subcommand2` for a two-level grammar (`gh repo list` and `gh repo delete` share their first level and only one is a hazard), `flag_values` for a flag's SETTING rather than its presence (presence alone would force a choice between missing `-X DELETE` and refusing `-X GET`; the value is compared without regard to case, since a constraint a change of case walks past is not one), `arg_paths` for a root segment AND an exact depth (the depth is the entry's scope: `repos/{owner}/{repo}` IS the repository, while DELETE on a branch ref under it stays ordinary work), and `arg_prefixes` for a hazard carried by an operand with no flag to look at (`git push origin +main:main`). The empty form of each is a load-time rejection, because an empty flag group defangs and an empty prefix over-blocks, and both are invisible in the file. Two behaviours are pinned as tests rather than left to be rediscovered: an api path written as a fully-qualified URL is NOT matched (a stated limit, the family of the payload gap), and `--help` is not an exemption anywhere in this registry — exempting it would mean teaching every entry which of its flags mean 'do nothing', and a guard that reasons about intent can be argued out of refusing. iss-144 needed no code at all: its incident shape was already known-bad fixture #1 and the incident capture already known-good #1, so the only change was adding the three-step form it actually occurred in (mkdir → cd → `rm -rf *`) to the corpus, making the resolve verifiable rather than asserted. Residual and deliberately not acted on: a `cd` in one tool call and an `rm -rf *` in the next is still an allow, since the tokenizer never sees two invocations as one candidate — a harness-level concern, not a registry one.
- dcn-26080100000000042026-08-01 — guard batch (iss-159/144/148), REVERT of the backtick sub-part after a THIRD adversarial round; the other three sub-parts stand. Following backtick command substitution went in, then grew a frame/stack mechanism intended to hold two properties at once — a substitution's content checked as its OWN segment (so a hazard hidden inside one is matched) while the ENCLOSING command's tokens, chain index and command position survived the boundary. Three rounds, three fresh independent reviewers, three BLOCKER findings, all in that mechanism rather than in one instance of it: (1) a substitution before trailing flags truncated the enclosing segment, so `rm` + substitution + `-rf *` went block to allow; (2) the frame fix for (1) renumbered the enclosing command's chain when a newline fell inside a substitution, defeating every `after_cd` entry (`cd scratch && rm -rf $(\nfind . -name x\n)`); and (3), the one that decided it, a substitution in LEADING command position left a bare `$` as the enclosing command's argv[0], so `commandOf` returned `"$"` and NO entry could match — that is not a hole in `rm-rf-after-cd-chain`, it is a hole in the registry, reachable by prefixing any hazard with `$(true)`. Two more in the same round: a bare `(` nested inside `$( … )` mis-popped the frame and re-opened (1) in a narrower shape, and an unterminated backtick failed OPEN. DECISION: revert rather than patch a fourth time inside one round. A mechanism that has produced a security-relevant regression on every attempt is not converging, and each fix has been strictly worse than the last in blast radius — the third defeats the entire registry. `internal/core/guard/tokenize.go` is restored byte-for-byte to `main`: no backtick handling at all (literal text), `$( … )` exactly as it was, and the four guard.json fixtures the frame mechanism added removed with it. The two claims of the entry above are therefore WITHDRAWN, not superseded: backtick substitution does NOT reach command position, and the enclosing command does NOT survive a substitution. The literal ledger ask ("a backtick command substitution is not followed, while the dollar-paren form is") asked for PARITY with `$( … )` as it behaves today, and parity is what leaving both forms untouched delivers — the gap is disclosed again as a stated v1 limit on all four coverage surfaces (brief, plugin command, CLI long help + generated reference, package map), which is the honest form of it. Recorded rather than quietly carried: `$( … )` on `main` already loses the flags written AFTER a substitution — `rm $(true) -rf *` does not read as a recursive force delete — a pre-existing gap this round's review process surfaced and did not introduce, undisclosed before it and undisclosed still, because closing it is the same design problem as following a backtick and the two should land together or not at all. iss-148 is re-scoped IN PLACE back to open on that one sub-part (the iss-96 precedent: a `capture resolve` would have to state a resolution for scope that is not settled), with the three shipped sub-parts and their commits named in the entry; iss-144 and iss-159 are untouched and stay resolved, neither being implicated in any review finding. Deferred as its own future work, and explicitly NOT a patch to the reverted code: a substitution model that follows the payload AND preserves the enclosing command across the boundary, which needs the tokenizer to carry a command-position stack rather than a segment flush — the reason three attempts to bolt it onto the flush all leaked.