Development record
2026-08-18 — The guard's parse layer is a MISTAKE FILTER, not a security boundary
2026-08-18 — The guard's parse layer is a MISTAKE FILTER, not a security boundary
(adr-42, iss-272). Enumeration-completeness is abandoned as the matching strategy:
Tier 1 (position-anchored, blocks) keeps the registry match; Tier 2 (position-agnostic,
warns) speculatively re-matches from each later command-position token when NO ENTRY
MATCHED, each suffix run through expandPayloads, converting the unbounded WRAPPER class
from silent allow to loud warn without enumerating anything — 6 of the 10 verified
bypasses, 7 once each speculative suffix runs through expandPayloads. It does NOT reach
the exec-string class (su/runuser/script -c keep an opaque payload isShellFamily will not
open), which stays silent until part C lands. Forced by three facts: the
defect is the wrapper path's missing fail-safe (the interpreter path has shellUnresolved,
the wrapper path has nothing), the wrapper set is unbounded in principle and its
tractable part cannot be read off the documentation (nsenter's --help renders -S as
optional-arg and unshare's renders the same letter, same package, same version, as
required-arg, while BOTH binaries consume the next token — so only probing the installed
binary is sound; gh-299 is the in-repo proof — its first list was taken from the bug
report and was wrong three ways, omitting --shallow-file (a live force-push bypass in git
since 1.9) and counting --exec-path/--super-prefix as value-taking when neither is, and
it TOTALLED NINE EITHER WAY, so a size assertion certified the wrong list as complete), and every vendor shipping this control documents it as
steering, not enforcement.
Tier 2 is BOUNDED against a measured quadratic DoS — the drafted form ran 14.9s at 6000
tokens, ~8h of CPU at the 1 MiB stdin cap, inside the PreToolUse hook — so O(N) starts, a
~64 cap, short-circuit, pinned by a benchmark. The gate is "no entry matched THIS SEGMENT" — never "argv[0]
unknown", which would switch speculation off for git, and never per-Check, which would
hand an author a one-token suppression (git clean -fd ; nice git push --force matches
git-clean, so a whole-command gate disarms the fail-safe for the whole line).
Per-segment is also what makes the false-positive figures a floor, but only with
"unknown command" pinned to commandOf's OUTPUT (wrappers, assignments and reserved words
stepped, basename taken) rather than the literal first token: matchSegment keys on exactly
that value, so a segment commandOf resolves to something no entry names cannot match any
entry, and the nesting is by construction. Under the literal reading it is false —
env git clean -fd gh repo delete . resolves to git and matches git-clean, while a
literal-token gate would see env and fire on git clean's own pathspecs.
Also decided: a synthetic verdict raised from a speculative suffix (expandPayloads emits
two blockers, envSpecialBlockSignal and depthBlockSignal) is DEMOTED to warn, never
honoured at its own tier and never dropped — honouring blocks on two layers of
uncertainty, dropping invents a second silent suppression path inside the fail-safe.
The ~64-start cap is PER SEGMENT for the same warn-rate reason. Enumeration continues,
demoted to an upgrade (warn → precise block) and safe to be incomplete. The exec-string
family (su/runuser/script/flock -c) gets a small table, NOT a generalisation of
shellCPayload, which would ship six new SILENT allows on the long spellings. Sequencing
D → A → B → C. Rejected with evidence: add-the-missing-names as the primary fix (third
instance of one defect); descend-into-any-spaced-token (breaks the 100% known-good floor —
the spc-16 incident-capture fixture fires); flag-shaped-prefix-only (silences most true
positives); allowlist (recorded for a future fail-closed mode, wrong for arbitrary developer
work). Accepted cost: false positives on unquoted hazard-shaped text wherever no entry matched —
0/1144 repo-mined lines, 21/79 adversarial, but BOTH corpora were run against an
argv[0]-gated prototype, i.e. the gate this decision rejects, so the figures are a FLOOR
(per the nesting above) with an unmeasured margin, and re-measuring under the adopted
gate is a merge precondition. The fail-safe covers the WRAPPER enumeration only: a
missing interpreter name (fish -c) and a missing git value flag are still silent allows
under Tier 2, so this record covers the predicted fourth instance only if it lands in the
wrapper set. The warn-storm STOP from the
2026-08-15 note binds: measure on real agent commands before merge.
Frontmatter
| id | dcn-2608180000000001 |
|---|---|
| type | decision |
| date | 2026-08-18 |
.abcd/work/decisions/dcn-2608180000000001-the-guard-s-parse-layer-is-a-mistake.md
open on GitHub ↗ · commit history ↗
mentions3
- 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
- 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.
- spc-16abcd-teaches-repo-agents-the-shell-commands-they-must-never