Development record
The shell guard reads a command substitution's output as an unknown word, but not a parameter expansion's. A dash glued to a variable (git push --$X origin main, rm -$F after a cd) is read as the literal text --$X, which names no flag, so every blocker allows it while its --$(echo x) twin blocks; and ${GIT:-git} standing in command position is compared as text. bash builds the hazard from either. Found while closing review2-guard (its finding 1 names --$X as pre-existing). The record named a second half, a substitution standing in command position, which fix round 3 of lane guard resolved (a name a substitution prints is every program its known tail allows); this record is the parameter-expansion half alone. Fix round 4 made a ${…} that carries a command substitution (${X:-$(…)}, --${X:-$(…)}) unknown from its ${ on (iss-2609252120211621). What remains is exactly a parameter expansion with no substitution in it: $X, ${X}, ${X:-word} and the other operators with literal words, and $@ / $*, standing as the command's program name, as a flag or glued to one, as an operand an entry constrains, or inside a payload the guard reads.
The shell guard reads a command substitution's output as an unknown word, but not a parameter expansion's. A dash glued to a variable (git push --$X origin main, rm -$F after a cd) is read as the literal text --$X, which names no flag, so every blocker allows it while its --$(echo x) twin blocks; and ${GIT:-git} standing in command position is compared as text. bash builds the hazard from either. Found while closing review2-guard (its finding 1 names --$X as pre-existing). The record named a second half, a substitution standing in command position, which fix round 3 of lane guard resolved (a name a substitution prints is every program its known tail allows); this record is the parameter-expansion half alone. Fix round 4 made a ${…} that carries a command substitution (${X:-$(…)}, --${X:-$(…)}) unknown from its ${ on (iss-2609252120211621). What remains is exactly a parameter expansion with no substitution in it: $X, ${X}, ${X:-word} and the other operators with literal words, and $@ / $*, standing as the command's program name, as a flag or glued to one, as an operand an entry constrains, or inside a payload the guard reads.
Grounds
- pursued: every spelling where a variable supplies a flag or a program name blocks as its substitution twin does, and the sweep adds no block on a whole command an agent writes; a variable-spelled flag or program that allows, or a new block on an everyday line, would show it wrong
Frontmatter
| id | iss-2609251824244354 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-2609251824244354-the-shell-guard-reads-a-command.md
open on GitHub ↗ · commit history ↗
mentions9
- dcn-26092500000000142026-09-25 — The shell guard's unknown-word reading and the allows it deliberately keeps (fix round 2 of lane guard, run A, closing review2-guard). What a command substitution prints is not in the command line, so the tokenizer marks where it goes and a word holding one is an unknown word (`internal/core/guard/unknown.go`), failing closed in every role it could play: led by a dash it is every flag its known text can still become, after a value flag it is that flag's value, and as an operand it is one operand of unknown value. Four allows stay, each named so it is not mistaken for a miss. (1) A word that is WHOLLY a substitution is read as an operand, never as a flag: that is how an everyday command spells its commit message and its branch (`git commit -m "$(cat msg)"`, `git push -u origin "$(git branch --show-current)"`), and reading it as every flag would refuse both, so `git push $(printf -- --force) origin main` is not seen. (2) An operand's `+` refspec prefix is read from its known text only, for the same reason: `git push origin $(echo +main:main)` is not seen. (3) A commit or push after `git config core.hooksPath <dir>` in the same line is allowed: the guard reads configuration a command carries for itself (`-c`, `--config-env`, `GIT_CONFIG_*`), not configuration an earlier command writes to a file, and refusing every `git config core.hooksPath` would refuse the ordinary one-time hook setup a repository documents; the one-command spellings block (iss-2609251640464212). (4) Parameter expansions (`--$X`) and a substitution in command position are not yet unknown words; that is iss-2609251824244354, deferred with its reason. Two over-blocks are accepted in the other direction: a short flag's attached value holding a substitution (`git commit -m"$(cat msg)"`) reads as every short flag, because the guard does not know which short options take a value; and `… | xargs bash` reads as a shell reading the pipe, though xargs hands it arguments.
- dcn-26092500000000152026-09-25 — Correction to the entry above on the shell guard's unknown word (fix round 3 of lane guard, run A, closing review3-guard). Its mark was not unforgeable as `internal/core/guard/unknown.go` claimed: an ANSI-C escape that decodes to NUL (`$'\x00'`, `$'\0'`, `$'\u0000'`) put the mark into a word after Check had dropped the line's own NUL bytes, and a blocked command's name behind one allowed (iss-2609252020432185). It is unforgeable by construction now: Check drops the line's NULs, and an ANSI-C string ends at its first decoded NUL, as bash ends it. An out-of-band flag on the word was weighed and not taken, because every reader in the package holds words as strings and the change would not have been contained. The same round makes the rule total: every reader of a word or a command name reads through unknown.go, an unknown dash-word is read both as a flag that stands alone and one that takes a value (and as a shell's `-c` or a verb's payload flag) before command position as after it (iss-2609252020507464), and a substitution in command position is every program its known tail allows, a shell, a wrapper and git among them, which resolves the command-position half of iss-2609251824244354; that record now names only the parameter-expansion half (`--$X`, `${…}`), still deferred with its reason, so allow (4) above is that half alone. A test parses the package and fails on a reader of a word's dash or a command's name that is not listed with how it reads the rule. Four over-blocks are accepted in the fail-closed direction, each recorded so it is not mistaken for a defect. (1) An unknown operand can be any subcommand, so a read-only command whose subcommand a substitution prints reads as the hazard its entry names: `gh repo $(echo view) o/r` blocks under gh-repo-delete and `git $(echo status)` warns under git-clean. (2) A program name nothing fixes can be `pkill` or `killall`, whose entries name nothing but the program and an operand, so an unknown name with any operand blocks under them (`"$(which python3)" script.py`; spelling the program's name is the way past); a markdown line whose backtick span stands in command position reads the same way, which is the only change the 4,110-line false-positive sweep showed (two prose lines, no command). (3) An unknown name followed by `-c '<string>'` reads the string as a shell's, so one the guard cannot parse warns as a shell payload would. (4) More than eight substitutions where a command's program name could be, and an unknown dash-word that can be env's `-S` with a value glued on that the guard cannot read, are refused: the first because every such name costs a read of the whole line, the second because the value is not a plain command.
- dcn-26092500000000162026-09-25 — Correction to the two entries above on the shell guard's unknown word (fix round 4 of lane guard, run A, closing review4-guard). Over-block (2) of the round-3 entry is wider than it says: any command word whose basename ends in a substitution (`$(date) x`, `"$(which go)" build ./...`, `$(command -v X) anything`, `/usr/bin/$(x) y`) followed by any one operand blocks, because killall-by-name and pkill-by-pattern name only a program and an operand; and the verdict carried killall's own lesson ("stop it by pid"). Where the only entries a command fires are ones its unknown program name can be, the verdict is now reported under the reserved id `program-name-unknown`, in the substitution family, naming the entry the line reads as and the way past, spelling the program's name; the entries stay in its matches, and an entry the line does name outranks it. A narrower `$(which NAME)`/`$(command -v NAME)` reading was weighed and not taken. Three further rulings of the round. (a) A line the tokenizer cannot split is BLOCKED on the hook (`command-unparsable`), not failed open: where the tokenizer is right no shell runs the line either, so the block costs nothing, and where it is wrong (the `$'\c'` misreading this round fixed, which swallowed the string's closing quote) a fail-open is a bypass of every blocker by construction. itd-103 ac-1 names the binary failing to run, which still fails open loudly, as do an unreadable payload and a registry that will not load; the check verb keeps reporting the error as a fault (exit 2); the 961-line repository corpus holds no unparsable line. (b) An unquoted here-document body is read as bash expands it: the command substitutions in it are commands of the redirecting command's chain and its text is data, and a delimiter written with a backslash (`<<\EOF`, `<<E\OF`) is quoted, as bash reads it. (c) A `${…}` in which a substitution's output lands is unknown from its `${` on, so a word that is wholly such an expansion falls under allow (1) of the round-2 entry exactly as `"$(x)"` does, and `git push "${X:-$(echo --force)}" origin main` is not seen. The plain-variable half (`$X`, `${X:-git}`, a `${…}` holding no substitution) stays iss-2609251824244354, deferred with its reason; it is not seen as the program name, as a flag, or inside a payload.
- dcn-26092600000000012026-09-26 — Release v0.11.0 is cut by autonomous run A, and the run's agenda line is: approve the publish step. Under ruling A2 of the product thinker's run A interview (2026-09-23 07:52Z: the run approves the release environment itself once every gate is green) and the product thinker's releases ruling of 2026-09-25T08:04:52Z ("cut additional releases if that makes sense, but bundle multiple intents for it"), the run approves the `release` environment's deployment of v0.11.0 only after the merge queue, the verify job and every other gate on the tagged commit report green, and stops with a handover instead if any does not. The cut: v0.11.0, impact breaking (decided by itd-2609212130136102, the verb consolidation, whose five surface removals the guard names), 193 records since v0.10.0: eleven shipped intents (ten additive, one breaking) and 182 resolved or declined issues (108 fixes, three additive, 71 internal and outside the changelog); two major findings pass on their recorded deferrals (iss-2609250834251447, iss-2609251824244354). Content commit 9ef24744, on top of ae116575, which the docs-currency gate's findings required. Both semantic gates ran at tier full. docs-currency-reviewer (Fable 5.1) read the first roll and found four: the install one-liners ended on the retired `abcd version`, which v0.11.0 refuses with exit 2 (major; fixed in ae116575), a CONTRIBUTING wording (fixed there too), and two changelog lines recomposed by the composer; the guard gap behind one of them is captured as iss-2609260543090196. The brief-surface cross-check (40 pinned checkers, Opus 5.5, at most four alive) found 130, all real at the content commit by an independent classification (Fable 5.1): the eight user-facing and four behaviour findings are captured as nine minor records (iss-2609260552249742, iss-2609260552247395, iss-2609260552246303, iss-2609260552247252, iss-2609260552256523, iss-2609260552256401, iss-2609260552250826, iss-2609260552254288, iss-2609260552251398), and the design-record drift goes to the systematic brief pass iss-2609091956001547. Landed before the cut on the cutting session's ruling: #717 (the shell guard cluster) and #718 (the race lane's declared timeout, after #717 was ejected from the merge queue by the default ten-minute package timeout).
- dcn-26092800000000062026-09-28 — A parameter expansion is an unknown word, read for what its value can spell and not for what an earlier command carried into it, and the reading keeps its over-reads, recorded so they are not mistaken for defects (lane drainG3, autonomous run A, on iss-2609251824244354; this supersedes allow (4) of the first 2026-09-25 entry and ruling (c) of the third, which parked the plain-variable half). `$X`, `$1`, `$@`, `$*`, `$-` and a `${…}` read whole to its own `}` leave the unknown word's mark where the value goes, so `--$X` is every flag it could become and `$GIT` in command position is every program its known text allows; `$$`, `$!`, `$?` and `$#` print numbers and stay text, as an arithmetic expansion's output is a number. Allow (1) of the first 2026-09-25 entry extends to a word that is wholly a variable: it is one operand, so `git push origin "$branch"` stays allowed and `git push $X origin main` is not seen. A string handed to a shell is read with each variable-only word written back out (`sh -c "… $X …"` reads `$X` as that shell does), so a bare variable in a string raises no new warn, which keeps the gap `shellRawUninspectable` names: a value holding shell syntax is not read. The 4,315-input false-positive sweep (Makefile recipes, script lines and whole scripts, the repo-mined and adversarial corpora, and 81 everyday variable lines) moved from 4,000 allow / 47 block / 19 warn to 3,991 / 49 / 26 with 249 unparsable lines unchanged, after three readings of a carried value were left out because together they added 37 blocks on ordinary work: a variable handed to a shell or `source` as its script is not a stream (`bash "$script"`), and a variable standing as the program fires no entry that names only its program and an operand (pkill-by-pattern, killall-by-name) and is not a bare interpreter inside a string (`"$GO" build`, `$EDITOR notes.md`). Those, a pid list carried through a variable, and `eval "$X"` (which allows, as it did) are iss-2609281134544802, deferred past v0.11.0 for a ruling. Over-reads kept, each the variable twin of a ruled substitution over-read: a variable program name with a variable first operand can be `git clean` and warns (`exec "$BIN" "$@"`, `"$BASH" "$GATE"`; five lines of the sweep), `git -c core.quotePath=false "$@"` warns under git-clean, `git grep` whose pattern holds a variable and a `(` warns under the fail-safe as its substitution twin does, two printf continuation lines of a script read on their own (`"$n" "$n" "$spec" "$n"`) block as gh-repo-delete, `git -c "$KV" commit` blocks as a commit that may move core.hooksPath, and a stream piped into a shell whose script is a variable (`curl … | bash "$f"`) blocks, because a word wholly an expansion may be no word. The kill-by-search reading gains three feeds in the same lane (iss-2609270036253187): an unquoted here-document's substitutions are the standard input of the command that opened it, a substitution runs with the pipe into its own command as its input, and a shell string is handed the output of the substitutions in its command's words as its positional parameters or text, so every command of such a string is read as handed it (`sh -c 'kill 4242' _ "$(pgrep make)"` blocks), the over-block the 2026-09-27 entry accepts for a string xargs runs.
- iss-2609252120211621The shell guard read a parameter-expansion word that carries a command substitution (a dollar-brace default or alternative holding one) with the closing brace as fixed text after the output, so the program name it could be had to end in a brace and a dash-word only a flag that did: a blocked command whose name or flag was the substitution inside such a default allowed (review4-guard finding 3). A plain variable with no substitution in it is the half iss-2609251824244354 defers.
- iss-2609262259360005The kill-by-search and by-owner readings (iss-2609251640452031) left spellings of a kill by name or selector unseen. The ordinary ones are read on fix/drain-guard (4cc129009): a brace or paren group ({ pgrep make; } | xargs kill), a search inside a shell string (kill $(sh -c 'pgrep make')), a kill inside a shell string xargs runs or one that reads the pipe (pgrep make | xargs -I{} sh -c 'kill {}', pgrep make | xargs bash -c 'kill "$@"' _, pgrep make | sh -c 'xargs kill'), a kill behind xargs and an unknown launcher (pgrep make | xargs myrunner kill, now a Tier 2 warn), a selector value attached with a byte other than a letter or digit (pkill -tpts/3, pkill -ubob.smith), and pkill -Ubob or -Gstaff attached, read once pkill's signal word is read as a signal first.
- iss-2609270036253187Two more paths by which a process search's output reaches a kill are not read by kill-by-search: an unquoted here-document whose body holds a command substitution, redirected into an xargs kill (a here-string of the same substitution blocks), and a command substitution inside a command that reads a pipe, which inherits that pipe as its standard input (an xargs kill in the substitution reads the search piped into its command). A third, a search handed to a shell string as a positional parameter the string's kill reads, is not read either: it is a pid carried through a variable, the half [dcn-2609250000000016](../../decisions/dcn-2609250000000016-correction-to-the-two-entries-above-on.md) (c) defers with iss-2609251824244354, unless it is read the way xargs's input to a string is ([dcn-2609270000000001](../../decisions/dcn-2609270000000001-the-kill-by-search-reading-follows-a.md)), which is a call for the next guard lane. Found while fixing iss-2609270028388291.
- iss-2609281134544802The shell guard reads a parameter expansion as an unknown word (iss-2609251824244354, lane drainG3) for the flags and program names its value can spell, but not as data an earlier command carried into the variable, and four classes stay unread: (1) a pid list a search printed into a variable (p=$(pgrep make); kill $p, or pgrep make | while read p; do kill $p; done), the plain-variable half [dcn-2609250000000016](../../decisions/dcn-2609250000000016-correction-to-the-two-entries-above-on.md) (c) parked on iss-2609251824244354; (2) a stream path in a variable handed to a shell or source as its script (bash "$f" after f=<(curl ...)); (3) shell text a variable holds, run through eval "$X", sh -c "$X", or placed in a string a shell runs, whose value may hold a separator or quotes (eval "$X" allows, as it did before the reading); (4) pkill or killall as a variable's value standing as the program with an operand ($P make), and a variable-named bare interpreter inside a string. Reading each would refuse ordinary commands: the false-positive sweep of 4,315 inputs showed 22 new program-name-unknown blocks ("$GO" build, exec "$BIN", $EDITOR notes.md) and 15 new interpreter-reads-stream blocks (bash "$SCRIPT") before these readings were left out. Classes 1 and 3 carry the same cost, from the sweep's everyday-variable lines, each of which allows today: a reading of class 1 cannot tell a pid a search printed from one the script recorded, so it would refuse kill "$pid", kill -TERM "$PID" and kill -- -"$pg" (a background job's pid, a pidfile's, a process group's); a reading of class 3 cannot tell a hostile string from a composed one, so it would refuse eval "$cmd", the line that runs a command a script built up. Each class is a block, warn or allow call the product thinker has not ruled on.