Development record
The guard's parse layer is a mistake filter, not a security boundary — matching is two-tier, and completeness is abandoned as a goal
Context
abcd guard reads a proposed shell command, walks past "wrappers" to find the
real command, and matches it against a registry of hazard patterns
(itd-103,
spc-16). Three separate defects have now been filed against the same shape:
| defect | the enumeration that was incomplete |
|---|---|
| gh-297 | the interpreter set (sh bash dash …) |
| gh-299 | git's global value flags (-c, --config-env, --shallow-file, …) |
| iss-272 | the wrapper set (sudo doas command env nohup time xargs timeout exec) |
Each was fixed by extending a list. A third instance of one defect predicts a
fourth, so iss-272 was investigated design-first rather than patched: a plan
drafted from the issue text went to adversarial review before any code and came
back with three blockers and four factual errors — a count the note revises
upward as it verifies the rest of the table. The evidence is in
2026-08-18-guard-wrapper-family-and-parse-layer-limits.md;
this record holds the decision it informed.
Three findings forced the framing.
The defect is an asymmetry, not a short list. The interpreter path already
fails loud on what it cannot resolve — shellUnresolved yields a warn, so a
payload the guard cannot read is never mistaken for clearance. The wrapper path
has no equivalent: an unrecognised wrapper simply becomes the command, nothing
matches, and the verdict is a confident allow. Ten wrapper bypasses were
verified live on the merged tree, nice git push --force among them, each
exiting 0.
The set is unbounded in principle, and the tractable part cannot be read off
the documentation. Any program that execs another is a wrapper. nsenter --help
renders -S/--setuid as optional-argument and unshare --help renders the same
letter in the same package at the same version as required-argument — yet both
binaries consume the following token, so nsenter's help contradicts its own
parser. A per-wrapper flag table can therefore only be derived by probing the
installed binary, and gh-299 is the in-repo proof, recorded in
gitglobals_test.go: the first cut of git's value-flag list was taken from the
bug report and "was wrong three ways" — it omitted --shallow-file, a live
force-push bypass present in git since 1.9, and counted --exec-path and
--super-prefix as value-taking when neither is. It totalled nine either way,
so a size assertion certified the wrong list as complete. A completeness check
that passes on a list wrong in both directions is the strongest available
argument that only probing the installed binary settles this. The grammar differs
again on macOS.
Every vendor shipping this control documents it as steering, not enforcement.
Claude Code calls argument-constraining patterns "fragile" and
refuses to implement Bash(command:rm *) because a compound command would bypass
it; Cursor shipped a command denylist, watched it fall to bash -c, subshells and base64 — the gh-297 gap verbatim — replaced it with an
allowlist, and then had that bypassed by poisoning an allowed command's
environment (GHSA-82wg-qcm4-fp2w / CVE-2026-22708). The posture is CWE-184 (Incomplete List of Disallowed
Inputs) and the literature behind it is settled —
Ranum's "Enumerating Badness",
Garfinkel (NDSS 2003).
Meanwhile the repo rates this class two ways at once: the GitHub-filed siblings
gh-297 and gh-299 both carry severity:critical — labelled "silent
security/data-loss at a trust boundary" — with adversarial framing
("agent-proposed commands are steerable by untrusted content the agent has
read"), while the ledger entry for the same defect, iss-272, is minor. Both
cannot be right, and the critical framing is a claim no denylist can honour.
Decision
1. The guard's parse layer is a mistake filter. It catches accidents and
casual evasion by a cooperating agent. It does not withstand a determined author.
Anything needing an actual boundary needs an execution-layer control behind it;
the parse layer never claims to be one. The guard does not say this today —
its help text gets as far as "an allow means no registry entry matched — it is
never a statement that a command is safe", and the surface brief says "coverage is
what the registry names", but neither states the scope in these terms. Saying it
is part D, the first thing built. The split rating is resolved with it: a missing
wrapper or interpreter name is a real defect in a mistake filter, not a silent
failure of a trust boundary, so severity:critical is the wrong label for the
class.
2. Enumeration-completeness is abandoned as the matching strategy, and replaced by two tiers:
- Tier 1 — position-anchored, blocks. A registry entry matched at command position after stepping known wrappers. Precise, and the only tier that blocks.
- Tier 2 — position-agnostic, warns. When no entry matched the segment, the
matcher re-runs speculatively from each later command-position token, and each
speculative suffix is run through
expandPayloads. A hit is a loud warn, never a block, because an unknown command is not proof that it execs its arguments. - A synthetic verdict raised from a speculative suffix is demoted to warn, and
never dropped.
expandPayloadsdoes not only match — it raises its own signals, and two of them are blockers (envSpecialBlockSignalfor anenv -Svalue it cannot prove plain,depthBlockSignalpastmaxPayloadDepth). Tier 2 reaches them:env -S 'git push --force $X'raises the synthetic blocker today whilemyrunner env -S 'git push --force $X'is allowed, becausesplitStringValuewalks only the leading wrapper chain. Honouring such a signal at its own tier would make Tier 2 block on two layers of uncertainty at once — an unknown command that may not exec its arguments, carrying a payload the guard could not read. Discarding it would invent a second silent suppression path inside the fail-safe. Demotion is the only option that keeps both invariants.
The $ has to sit inside the quoted -S value, where isPlainCommand
tests it. The unquoted env -S git push --force origin main also blocks, but on
the ordinary git-push-force entry — envInspect decodes the value and
rebuilds the command — so it looks like this phenomenon and is not it.
"Never dropped" binds the verdict, which is all the safety argument needs:
a demoted signal keeps the line at warn. It cannot bind the reported message
as Check is shaped — the merge keeps only the first synthetic per pool, so a
demoted signal either loses the message to an existing shellUnresolved or
displaces one. Settling that precedence is the implementation's, with one
requirement this ADR does set: a Tier 2 verdict must be distinguishable from
a Tier 1 one in what the user is told. Decision 2's whole rationale is that an
unknown command is not proof it execs its arguments, and a reader who cannot
tell a speculative warn from a literal one has not been given that.
Tier 2 converts the unbounded wrapper class — a command that execs its own
arguments — from silent allow to loud warn without enumerating anything, and
gives the wrapper path the fail-safe the interpreter path already had. It does
not reach the exec-string class: su -c, runuser -c and script -c keep
their payload as one opaque token that isShellFamily will not open. Six of the
ten verified bypasses are covered by speculation, seven once each speculative
suffix is run through expandPayloads; the remaining three are part C's whole
reason for existing.
Shipped state. All four parts are built. Of the ten bypasses, part B turned
seven into precise Tier 1 blocks and part C reached the other three, so none is
an allow and the fail-safe now stands underneath the enumeration rather than
behind it. The gate condition acquired a third clause in the building: a segment
whose payload the guard successfully READ is not an unrecognised launcher either,
or busybox sh -c "<hazard>" reports the same hazard twice — once as the entry
its payload matched and once as a guess about the parent.
3. Tier 2 is bounded against the denial of service it would otherwise
reintroduce. The obvious form of speculation is N starts × E entries — exactly
the per-entry splice-and-restart payload.go forbids by name. Prototyped, it
is strictly quadratic: 14.9 s at 6,000 tokens, and roughly eight hours of CPU
inside a PreToolUse hook extrapolated to the 1 MiB stdin cap. The required
shape is therefore binding: command-position starts computed once per segment in
an O(N) pass, a hard cap of ~64 starts per segment past which the warn is
emitted unconditionally rather than skipped, short-circuit on first hit, pinned by
a benchmark test.
Amended when built: the start cap alone does not bound the cost. Every start
pays commandOf, which walks the whole leading wrapper chain, so 64 starts on a
1 MiB line of env env env … measured 14.2 s — linear in line length, and
still a hang inside the hook. A second bound was added: the tokens one start
reads are capped (maxSpeculativeWindow, 512), taking the same input to ~120 ms.
That is a coverage trade, and a small one — every pattern reads its command, the
operand at position 0, and flags, all of which sit near the command in any hazard
the Pattern language can express — but it is a trade, so a truncated window warns
through the same fail-loud path as an exhausted start cap rather than falling
quiet.
Both granularities bound the cost: per segment gives Σ 64·len(seg)·E for entry
matching, plus the expandPayloads call each speculative suffix now makes, itself
O(line × maxPayloadDepth) and including a re-tokenize — so the total stays
linear in line length at a constant of 64·2. The benchmark must exercise the
payload path, or it pins only the cheaper half. What the two granularities do
not share is warn behaviour, which is why the cap is specified here rather than
left to the implementation: a per-line cap spent early would fire the
unconditional warn on every remaining segment of a long line, and decision 8
makes warn rate the STOP.
4. The Tier 2 gate is "no entry matched this segment" — never "argv[0] is unknown", and never "no entry matched the command line". Two separate corrections, each with a counterexample:
- Not argv[0]. An argv[0] gate switches speculation off for
git, and git launches things —git bisect run,git submodule foreach,git rebase --execare silent allows today and would stay silent under it. - Per segment, not per
Check.Registry.Checkcollects matches across every segment of the command line and merges them, so a whole-command gate hands an author a one-token suppression:git clean -fd ; nice git push --force origin mainmatchesgit-clean, which would switch speculation off for the whole line and leave the wrapped force-push silent. Prefixing any warn-tier command would disarm Tier 2 entirely. The gate is therefore evaluated per segment, and a segment that matched nothing is speculated on regardless of what any other segment did.
"Unknown command" means commandOf's output, never the literal first token.
commandOf steps wrappers, environment-assignment prefixes and reserved words,
then takes the basename, and matchSegment's first test is cmd != p.Command
against exactly that value — so sudo git push --force, FOO=bar git push --force and /usr/bin/git push --force all match entries despite a first token
no entry names. With the term defined that way the nesting the floor claim rests on is by
construction: if commandOf resolves to something no entry names, no entry can
match that segment — matchSegment tests that first, before every other pattern
field — so every fire of a gate so defined is also an adopted-gate fire. One half
of the claim is therefore checkable from match.go; the other half, that the
prototype gated on commandOf's output rather than the first token, is
testimony, since the prototype lived in the gitignored local tier. It becomes
checkable when the corpora land as committed test data, and not before. Under the literal
reading the nesting is simply false — env git clean -fd gh repo delete .
resolves to git and matches git-clean, while a literal-token gate would see
env, speculate, and fire on git clean's own pathspecs. The term is defined
here because that ambiguity makes the difference between a floor and no floor.
5. Enumeration continues, demoted to an upgrade. Adding wrapper names lifts a case from "loud warn" to "precise block" — better UX and a stronger verdict — and it is safe to be incomplete precisely because Tier 2 backs it. It is no longer the fix.
6. The exec-string family gets a small table, not a generalisation of
shellCPayload. su -c, runuser -c, script -c, flock -c take a command
string a shell executes, but none is a shell. Generalising the existing helper
resolves every short spelling free and returns shellNone for every long one
(su --command, --session-command, …) — six new silent allows, not even
the shellUnresolved fail-safe. A command → payload-flag table is the correct
shape and leaves that contract untouched.
7. Sequencing is D → A → B → C, the four parts being:
| part | what it is | decisions above |
|---|---|---|
| D | the scope statement in the guard's own documentation | 1 |
| A | the bounded Tier 2 fail-safe | 2, 3, 4 |
| B | wrapper names added to wrappers / wrapperOperands |
5 |
| C | the exec-string command → payload-flag table | 6 |
Decisions 7 and 8 are not parts: 7 is this table, and 8 is a gate that binds all four.
D leads because shipping enumeration without it manufactures exactly the false confidence that makes the next gap dangerous. C is last but not optional: until it lands, three of the ten verified bypasses are still silent.
8. The warn-storm STOP binds this work. Per
2026-08-15-guard-execute-string-family-design.md:
a warn rate that trains users to ignore warns is a STOP, measured on real agent
commands before merge.
Alternatives Considered
Add the missing wrapper names (the issue's own proposal). Cheapest, and it closes the ten verified bypasses. Rejected as the primary fix: it is the third instance of the identical defect, the class is unbounded, and it leaves the silent-allow verdict intact for everything not listed. Retained as decision 5, below the fail-safe.
Speculative re-match in its drafted form. Rejected as drafted for the
quadratic DoS above, and because the coverage claim behind it was wrong —
speculation alone catches 6 of the 10 verified bypasses, not all of them, since
matchSegment never descends into a payload. Adopted only in the bounded,
expandPayloads-composed form of decisions 2–4, which recovers busybox sh -c
at zero additional false positives.
Descend into any argument token containing a space. Catches everything,
including su -c and su bob -c, at +2 false positives. Rejected with evidence:
it breaks the shipped known-good gate — abcd capture "git clean -fd wiped my scratch notes" fires, and that fixture is an incident capture, the case spc-16
says the design turns on. The true-negative floor is 100%.
Speculate only when every token before the suffix is flag-shaped. Silences
the rg -F git push --force docs/ false positive. Rejected: it also silences
find . -exec, chroot /jail, flock /tmp/l, chrt -f 99, taskset -c 0,
docker run, ssh host — most of the true positives.
Invert to an allowlist. The only shape that is complete by construction, and
what Claude Code's read-only fast path does. Rejected for this surface: the guard
sits on PreToolUse for arbitrary developer work, where an allowlist either
blocks ordinary commands or grows into the same unbounded list from the other
side. Recorded rather than dismissed — if the guard ever gains a fail-closed
mode, this is the shape it takes.
Demote the parse layer to advisory and put the boundary elsewhere. Partly adopted: decision 1 is the demotion of the claim. Not adopted in full, because removing the block tier would discard precise, correct verdicts on the cases the registry does match, and abcd is host-delegated by default (adr-25) — the execution-layer boundary belongs to the host, not to abcd.
Consequences
- The guard stops lying about the wrapper cases it cannot see. An
unrecognised wrapper becomes a loud warn instead of a confident allow, and
that is not hostage to a list staying complete. The exec-string family is not
covered by the fail-safe — part C covers it by enumeration, and until part C
lands
su -c,runuser -candscript -care still silent. - False positives become the accepted cost, and the measured figures are a
floor rather than an estimate. The corpora — 0 fires across 1,144 repo-mined
lines, 21 of 79 on an adversarial corpus, 2 of those true positives — were run
against a prototype gated on argv[0], which is the gate decision 4 rejects.
Because decision 4 evaluates the gate per segment, every prototype fire is also
an adopted-gate fire — the figures are a floor and not merely a different
measurement — and the margin above it is unmeasured:
git grep git push --forcematches no entry today, so the argv[0] gate silenced it while the adopted gate speculates and warns. A whole-command gate would not have this property, and is rejected in decision 4 for a worse reason than noise. The shape is unquoted hazard-shaped text in a command line where nothing matched; quoting silences it. This repo's subject is the hazard registry, so expect interactive hits here specifically. - Discharged when built. Both corpora were re-measured against the shipped
implementation and now live in
internal/core/guard/testdata/corpus/: 961 command lines mined from this repository's prose, Makefile, scripts and workflows, on which Tier 2 fires zero times, and a labelled adversarial corpus carrying all four directions. The counts move as parts B and C upgrade warns to precise blocks, so the corpus file and its test's own log line are the figure — not a number copied into this record, which is how the first version of this paragraph came to disagree with the data it was discharging. The real implementation is substantially quieter than the argv[0]-gated prototype predicted, because a hazard inside a quoted argument or a path never reaches command position at all. The ceiling is enforced by a test, and raising it is the visible cost of making the guard noisier. - A new standing obligation: the DoS bound is load-bearing, not an optimisation. The start cap and the O(N) pass carry a benchmark test, and any future change to matching re-runs it. The guard gates the hook; a slow guard is an outage.
- The evidence is committed, not recounted. Both corpora and the DoS bound
ship as test data and tests (
corpus_test.go,speculate_bound_test.go,BenchmarkCheck), so every figure above is now a claim the next change has to keep true rather than a run the reader has to take on trust. - Wrapper and payload-flag tables are documented as one platform's grammar.
nsenter unshare chrt taskset ionice setsid flock stdbuf runuserdo not exist on macOS, andsu,script,chroot,nicecarry BSD grammars there. CI runs both platforms, so the table says which platform it encodes. su -cis not guaranteed to be shell grammar — it runs the target user's login shell, overridable with-s. This costs false negatives only (a mis-parse yields a non-match, never a false block), and it belongs in the scope statement rather than in a claim that the family parses uniformly.- Residuals stay open and named, so no reader mistakes the fix for a boundary:
top-level
curl … | bashis a silent allow today (pipesIntoInterpreteris consulted only inside a payload — separate defect, found while testing), andecho <b64> | base64 -d | sh,make push-force,npm run deploy, shell functions, aliases and variables are permanently invisible to any parser. - The next gap in the wrapper enumeration is no longer an incident — the
other two enumerations are untouched. A missing wrapper name degrades a block
to a warn instead of to silence, which is the whole point of spending the false
positives. The fail-safe does not extend to the sibling enumerations this ADR's
own Context table names: a missing interpreter name (
fish -c "git push --force …") and a missing git value flag (git --newglobal X push --force …) are both still silent allows under Tier 2, for two different reasons. The interpreter case hides the hazard inside one opaque token: a start does land on that token, but it resolves to a command name no entry knows andexpandPayloadswill not open it, becausefishis not inisShellFamily. That one is repairable by enumeration — it is gh-297's shape. The git case is worse: a start does land onpush --force origin main, and it resolves to the commandpush, which no entry names — the gh-299 class displaces the operand after the command-position boundary, and speculation only ever moves that boundary, so no number of extra starts can reach it. Stating it as "no start lands on it" would invite the repair that cannot work. The thesis is that a third instance predicts a fourth; this record covers the fourth only if it lands in the wrapper set. - Two labels are now wrong and are corrected with part D. gh-297 and gh-299
still carry
severity:criticalfor a class decision 1 rules out of that category. Relabelling them is part of the scope statement's landing, not an optional tidy-up: leaving them is the same false-confidence signal from the other direction.
References
Frontmatter
| id | adr-42 |
|---|---|
| type | adr |
| status | accepted |
| date | 2026-08-18 |
.abcd/development/decisions/adrs/0042-guard-parse-layer-is-a-mistake-filter.md
open on GitHub ↗ · commit history ↗
References2
- adr-25The LLM is host-delegated by default; oracles are opt-in adaptersrelated
- 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."related
Referenced by5
- adr-2610091150447054The guard reads a script the command names before it judges the commandrelated
- adr-2610161621415708Unattended runs abcd launches execute inside an OS sandboxrelated
- adr-2610240850193281A human-set autonomy grant permits, separately from the agent-written waiting-on badgerelated
- adr-2611011813190527A repository declares a sequence's shape but never a widening flagrelated
- itd-2610161621364075The agents abcd launches on its own work inside a sandbox the operating system enforcesrelated
mentions25
- abcd-fixes-what-it-addsabcd fixes what it adds
- adversarial-review-scales-with-blast-radiusAdversarial review scales with blast radius
- dcn-26081800000000012026-08-18 — The guard's parse layer is a MISTAKE FILTER, not a security boundary
- dcn-26081800000000022026-08-18 — adr-42 parts D and A built. TWO amendments the build forced, both recorded
- dcn-26081800000000032026-08-18 — adr-42 parts B and C built; iss-272 closed. Part B named fourteen wrappers
- dcn-26081800000000042026-08-18 — Two adversarial reviews of the adr-42 implementation; three findings that
- dcn-26101618214200882026-10-16 — Refines dcn-2610161624025493: the headless approver's own checks read the guard's parse of the line, not its raw text. Writes and removals are the targets the guard's write model resolves (writesOf), in every shell state the reading placed each segment in, plus the operands of touch, mkdir, chmod, chown, truncate, rm, rmdir, unlink, mv's sources, ln's link targets and a sed script's w commands; a target the line does not fix (a variable, a substitution's output, a glob, or a relative name after an unknown change of directory) is refused, and a fixed one is compared, with its existing directories resolved through symlinks and its case folded where fsutil.CaseFoldingFS says the filesystem folds, against the protected set: the git common directory with its hooks and config, each core.hooksPath directory, each checkout's .git, the home shell start-up files and git configuration, the host's settings directories, .mcp.json and .abcd/guard.json, plus the harness's protected names as path components relative to the working tree or home. A line that only names a protected path is no longer refused. git config is refused with --global, --system or --file on a write, on setting a key that starts a program (the gitutil.ExecPins keys and git's documented program settings) and on opening the editor, and so is any git command setting such a key for itself. The approver takes --base, the lane's base or the worktree's HEAD at an interview's launch, and the working tree's registry is in force only where it is at least as strong as the one at that commit: any weakening, of a warn or a blocker, committed during the run or not, refuses every command, and a shell launch with no base is not started. An allow returns only the command and its description, and a call asking to run outside the host's sandbox is refused. Critical removals widen to home's direct children and the repository's other checkouts, and to a find that deletes from home or above, a move of a critical directory, an rsync --delete into one and a recursive chmod or chown of one. Code reaching an interpreter outside -c/-e is refused: on the standard input of a listed interpreter or ex that names no script file, an awk program that runs a command, a sed script with the e command or flag, tar's options that run a program, and a vim or ex command holding a !. A glob in a removal target is refused as not fixed except where it lists a critical directory, which the critical check refuses first. The checks read the line itself; a script the line runs is read for the registry's hazards, not for its own writes. The guard stays a mistake filter (adr-42) and this adds no sandbox (dcn-2610161619296615).
- dcn-26101620400341172026-10-16 — Refines dcn-2610161930191632: an approved command comes back as cd -- '<dir>' && {, a newline, the command, a newline and }, so nothing of it runs when the change of directory fails. The approver's list of agent launches (the program names, the flags that turn a harness's own checks off, the wrappers and package runners it reads through, the packages that ship a harness and the variables a harness reads its settings from) is a mistake filter in adr-42's sense: it steers an agent that starts another harness by an ordinary route. A harness started by a route outside that list, such as another program name, a copied binary or a configuration file it reads, is bounded by the OS sandbox for launched agents (spc-2610161751056096), not by the list. So a further form is added to the list when it is common, not to close the class. <!-- record-lint: forward-looking -->
- dcn-26101711123847122026-10-17 — The headless approver closes the arithmetic routes a review found by reading the text as bash will read it where that is cheap: it joins a backslash-newline before scanning, refuses an alias or alias expansion turned on, a $'…' string that writes a byte by its code, and a builtin whose operands bash may read as arithmetic spelled with a quote or a backslash in its name; it reads a substring offset on any parameter and an array index only in an assignment, an expansion or a name a builtin is handed, so a regular expression's character class is no longer refused. It stops there by decision, not by oversight: on the default branch a launched role is granted the shell with no approver at all, so every route the approver misses is one the run has today; reading a shell line well enough to refuse every hidden command is not finite; and the approver stays a mistake filter (adr-42), with the boundary for launched agents the OS sandbox spec (spc-2610161751056096). Grounds: the default branch grants Bash in the implementer and reviewer roles (internal/core/implement/loop/drive.go). Refines dcn-2610170944153720.
- dcn-26101810034564442026-10-18 — Only the person sets the away mode, its push rights are narrow, and the guard's refusal is a mistake filter (facilitator-on-delegation, itd-2610180912189125 review round 2, choice (a); refines dcn-2610180913399042, replacing its clause that an agent sets the mode on the person's word): the prompt hook cannot tell the person's message from one another session relays, and its input names no origin, so a relayed word could let an agent grant itself, and every copy, push rights. So the guard refuses every route to setting the mode that it reads: `abcd mode autonomous` by the binary on PATH, the plugin root's binary or a built `abcd-<goos>-<arch>`; a `go run` of a package ending in `cmd/abcd`; any of them inside `sh -c` or in a script file a shell runs (adr-2610091150447054); a shell write into the away store under `~/.abcd.noindex/away/`; and an Edit or Write tool call on a path inside it. The person sets the mode with their own `!` command, which the host runs directly without going through Claude and so without the pre-tool hook, or at a terminal. A refused attempt is recorded in the store and shown to the person on the next prompt. What the mode grants is the session committing and pushing its own branches and opening their pull requests; never the default branch, a tag, a release cut, another person's pull request, a branch this copy only checked out to review, or a force push; merging follows the repository's own rule. This is chosen over the fallback, and is enough for now, because the grant only governs an agent that follows refusals: one that ignores them can push without any mode, so no shape of the mode adds a boundary inside the person's own session; the guard stops a refusal-following agent from granting itself on a relayed word; the rights are narrow, and every push is reversible and visible on the forge; and the record (who set it and from which copy) and the recorded refusals leave a trail the person reads on return. The guard stays a mistake filter (adr-42, and the decision of 2026-10-17 on the default branch that keeps the headless approver one, naming the OS fence as the boundary). That fence (spc-2610161751056096) is the boundary only for agents abcd's runner launches, whose writes to abcd's home it confines to their own worktree; it does not fence the person's own interactive session, where the stall happened, so there the narrow grant and the trail are the mitigation. The fallback (the mode grants no push rights, and the person's own standing instruction stays the authority to push) applies instead if the live capture shows that a message relayed from another session can run as a `!` command, or that an agent's tool call can reach the host's shell mode (grounds: plan review round 2, choice (a), and the coordinator's two addenda; the host's interactive-mode page, which says shell mode runs commands directly without going through Claude and outside the sandbox that applies to Claude's commands; the abcd-source-ledger-flip guard entry; adr-2610161621415708)
- dcn-26103013090398612026-10-30 — A find that deletes each match with rm and no recursive flag (find . -name x -exec rm {} \;) is not a guard hazard: it removes only the files the find names, one at a time, the same everyday delete dcn-2609280000000003 allows as rm -rf $(find ...), and a blocker there would teach sessions to route around the guard (adr-42, a mistake filter). Decided by the technical facilitator on delegation while judging iss-2609300536290307.
- iss-272The guard's WRAPPER set has the same incomplete-enumeration shape gh-297 fixed for interpreters. gh-297 made the execute-a-string INTERPRETER family one shared predicate (sh, bash, dash, zsh, ksh, mksh, ash). The wrappers map in internal/core/guard/match.go is a separate list of commands that merely exec another, and it omits several that are equally ordinary: nice (and 'nice -n 5'), setsid, flock, stdbuf, ionice, unshare, chroot, and busybox sh -c / busybox ash -c. Each is a silent allow today for every bundled blocker -- and equally so before gh-297 with plain sh, so none is introduced by that change. Also absent from BOTH sets: su -c '<hazard>', runuser -c '<hazard>' and script -c '<hazard>' /dev/null, which are execute-a-string interpreters in exactly the sense gh-297's rationale describes (each runs its -c operand with the grammar the tokenizer already parses) but are not shells, so they belong in neither list as currently shaped. The realistic ones are nice, setsid, busybox sh and su -c. Out of scope by contrast: ssh host '<cmd>' and docker run img sh -c '<cmd>' execute on a different host or namespace. Note for whoever takes this: gh-297's comment now says explicitly that 'nowhere left to widen' covers the interpreter set only, not the wrapper set, so the claim and this gap do not contradict each other. Found by the security review of gh-297.
- iss-296The hand-maintained ADR index table (.abcd/development/decisions/adrs/README.md) ends at adr-42 and omits committed records adr-43 and adr-44; the record-lint index_drift rule registers four indexes but not the ADR index, so this largest hand index is ungated. Same file: .abcd/work/rulesets/README.md says 'seven required status checks' where main-protection.json now lists eight (external-review added)
- iss-315guard CHANGELOG code comment and test claim python -c and perl -e already get a loud warn but the shipped behaviour is a silent allow and the pinning test is vacuous
- 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-2609012040008545GHSA-3w99-pgv4-8g55 (draft advisory, severity low; CWE-184, Incomplete List of Disallowed Inputs): a hazard whose argv bash rewrites by glob expansion is an allow. `tokenize` (internal/core/guard/tokenize.go) has no glob branch — `*`, `?` and `[...]` fall to the default word branch and quotes are stripped as they are read, so nothing records that a metacharacter was unquoted — and `matchSegment` (internal/core/guard/match.go) compares the command name, operands 0/1 and every flag alternative literally. So `git pus? --force origin main`, `git push --forc? origin main`, `g?t push --force origin main`, `git push --for[c]e origin main`, `git push --force* origin main` (which expands to --force-with-lease, a member of the entry's own flag group) and the same spellings inside `sh -c` / `eval` payloads all allow, while a file named push, --force or git in the working directory makes bash run the hazard. Tier 2 cannot reach either shape: a flag is never a speculative start and a globbed command name resolves to a name no entry knows. The fix must record per token that it carried an unquoted, unescaped glob metacharacter, and at the positions an entry constrains — command name, subcommand/subcommand2, flag and flag-value alternatives — treat a literal the glob CAN produce (path.Match, decidable from the string with no filesystem read and no cwd) as produced, the fail-closed reading: the guard cannot see the post-cd directory and a file named for the hazard is the cheapest condition. Unconstrained positions are untouched, so `ls *`, `git add *.md`, the quoted and backslash-escaped spellings and the repo corpus stay allow; a segment behind the zsh `noglob` wrapper skips the compare. Siblings named as out of scope: the `wrappers` lookup in commandOf (`sud? rm -rf *` reaches Tier 2's warn, acceptable); the `isShellFamily`, execStringVerbs and singleStringLaunchers lookups (a globbed interpreter name keeps its payload one opaque token, a silent allow of iss-315's shape); `pathArgMatches` (a path glob needs a directory tree named for it in cwd); the `--flag=value` and short-cluster forms of a globbed flag and extended globs (extglob, `**`), which path.Match does not model — the simple compare is a floor; `set -f` as a separate segment (over-block direction, rare); tilde, $VAR and $(…), which need state outside the string and are adr-42's documented residual.
- iss-2609012040016236GHSA-m2r8-fx7r-rq34 (draft advisory, severity medium; CWE-184, Incomplete List of Disallowed Inputs): the guard allows a git hazard whose subcommand git rewrites from in-process config the matcher steps over. `operands` (internal/core/guard/match.go) advances past the value of every flag in an entry's value_flags without reading it, and every bundled git entry lists `-c` and `--config-env` there (gh-299); `commandOf` steps `NAME=VALUE` prefixes the same way. So the alias body is the token stepped over and the attacker-chosen name is what reaches operand 0: `git -c alias.p='push --force' p origin main`, `FORCE_ALIAS='push --force' git --config-env alias.p=FORCE_ALIAS p origin main`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.p GIT_CONFIG_VALUE_0='push --force' git p origin main`, the unlisted sibling `GIT_CONFIG_PARAMETERS="'alias.p=push --force'" git p origin main` (verified live on git 2.52), and the bang-form `git -c alias.p='!<shell command>' p` are all silent allows while git runs the rewritten command. The fix must add a core pre-pass in Check that, for a segment whose command is git, collects the in-segment alias declarations from those four sources and, when operand 0 names one, appends a synthetic segment with the alias expanded (the original global flags, the body's tokens, the arguments after operand 0) so Tier 1 matches it with the existing entries; a bang-form body is handed to shellInspect exactly as an exec-string payload; nested aliases are followed a bounded number of hops. What the string cannot decide: `GIT_CONFIG_GLOBAL=file`, `GIT_CONFIG_SYSTEM=file`, `-c include.path=file` and includeIf deliver the alias body in a FILE, the same class as adr-42's permanently-invisible residual (make targets, shell functions, aliases) — the directive is visible, so each raises a loud synthetic warn (`git-config-rewrite-unread`, also raised for a `--config-env` whose variable is not assigned in the segment), measured against the corpus warn ceiling, which carries zero such lines. Hiding-alias regression decided on the side of fewer moving parts: git ignores an alias that names a builtin, so `git -c alias.push='push --force' push origin main` is a harmless push that the rewrite over-blocks; that over-block is accepted as the fail-safe direction and pinned as a known false positive, rather than shipping a probe-synced 146-name builtin table. `git -c alias.st=status st` allows under the rewrite because its expansion is benign. Unswept family, out of this advisory's scope: the same stepped-over value running a command on a benign subcommand — `-c core.pager=<cmd>`, core.editor, core.sshCommand, credential.helper=!…, diff.external, filter.*.clean — which no entry names; and the registry has no bare `rm -rf /` entry, so the advisory's bang-form example inspects cleanly and allows on the registry's terms (adr-42 decision 5), the mechanism being what this fixes.
- iss-2609012040019014Lab finding lab-260831162806-976575f, probe p5b-live-session-retest: the PreToolUse guard payload carries only tool_input.command, and a destructive command moved into the tool's workdir slips the match. (1) Contract fact: `guardHookInput` (internal/surface/cli/guard.go) reads cwd, tool_name and tool_input.command, and cwd is used only for rulesRoot; a host whose shell tool has a second execution-shaping input (opencode's `workdir`; any host passing a per-call cwd or env) lets a model move meaning out of the string the guard reads. (2) Evidence: the lab's plugin-guard.log records 18:36:58 allow exit 0 for `rm -rf *`, then 18:37:40 block exit 2 for `cd scratch && rm -rf *` — the model had rewritten the chain into workdir=scratch plus `rm -rf *`. (3) The bypassed entry is rm-rf-after-cd-chain, whose why is that a FAILED cd leaves the delete running wherever the shell already was; bare `rm -rf *` is not a registry hazard by design (its successor is an absolute-path delete as its own command). A host-honoured workdir is a different mechanism from a shell cd: whether the failed-cd hazard exists at all depends on what the host does when workdir does not exist — fail the tool call (no hazard) or fall back to the session directory (the hazard) — and the probe evidence does not answer that, so it must be probed on that host before either branch is chosen. (4) No shipped surface carries the field today: hooks/hooks.json wires the Bash matcher only, and the shipped host's Bash tool input has command, timeout, description, run_in_background; the opencode adapter is lab-tier (itd-22 lineage), nothing of it in the tree. (5) The fork, for the maintainer: (A) extend the guard contract — guard.Check in core gains a context carrying the host's workdir/cwd and entries may constrain it; the naive form where the hook prepends `cd <workdir> && ` before Check is WRONG and must not be the implementation, because it fires the cd-chain entry on every workdir'd recursive delete, whose failed-cd hazard the host-managed chdir does not have; versus (B) document the reach — add to the guard brief chapter's 'what an allow still does not see' and to adr-42's scope that the guard reads the command string, a host input that changes where or as whom it runs is outside the read, and a host adapter is responsible for folding such inputs into the string it submits or into a documented field. adr-42 decision 1 (mistake filter; the execution-layer boundary belongs to the host, adr-25) leans toward B, but A is a core API change the ADR does not settle. (6) Severity minor, category observation; stays open until the maintainer picks.
- iss-2609020543544634A here-document delimiter that is not letter-led fell to the arithmetic-shift branch, so the DOCUMENT was tokenized as commands and one apostrophe in the body raised ErrUnparsableCommand — which the pre-tool-use hook maps to fail-OPEN, leaving any hazard on a later line to run unguarded. bash accepts a WORD after <<: 'cat <<20', 'cat <<$D' and the '<<20' of 'let mask=1<<20' are all here-documents (the shell tokenizes the redirection before the let builtin sees arithmetic), and each of those spellings reached exit 2 / NOT CHECKED on v0.7.0. Evidence symbol: isDelimStart (internal/core/guard/tokenize.go), consulted at the '<<' branch of tokenize. It also falsified the claim both front doors make — that a quote inside a here-document body is document text and never unparsable. The fix must read the conservative superset of delimiter words (letter, digit, underscore, $-led) so the body is data, with the enclosing arithmetic context left as the sole shift discriminator.
- iss-2609212142557657The guard's hook payload omits a per-call working directory, so a command moved into it slips the command-string match. guardHookInput reads the session cwd and tool_input.command and nothing else; a host whose shell tool takes a per-call working-directory field (opencode's does; a future Claude Code field would) can carry a destructive command whose target is the directory rather than an argument, and the match over argv sees nothing hazardous. Live-demonstrated in lab 2's smoke (probe p5b-live-session-retest under the lab home): the model set the tool's workdir and ran the bare destructive verb. Verified at main on 2026-09-21: the struct is unchanged. Decision owed to the product thinker: extend the guard contract to read a working-directory field where the host supplies one and resolve the command against it, or document the reach in adr-42's mistake-filter scope as outside the boundary. Not a security boundary by adr-42's own statement, which is why the severity is minor; the category is security because the slip is the shape a mistake filter exists to catch.
- itd-114Two Agents Can Mint At The Same Instant And Never Collide
- itd-2610240850336695The person grants their sessions what they may do alone, until a time
- spc-16abcd-teaches-repo-agents-the-shell-commands-they-must-never
- spc-2610161751056096a-person-who-leaves-abcd-to-run-on-its
- spc-2610180914469489A session left to work on never stops while it has work