Development record
The 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.
The 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 (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.
Deferral 2026-09-28
Deferred past v0.11.0: lane drainG3 (run A, 2026-09-28) built the parameter-expansion reading of iss-2609251824244354 and measured it: reading a variable's carried value as a pid list, a stream path, shell text or a pkill/killall program name refuses ordinary commands (22 program-name and 15 stream blocks in the 4,315-input sweep), and leaving it unread keeps each spelling open; whether each class blocks, warns or allows is a product ruling not yet made, so these four classes, and only they, are carried past v0.11.0.
Deferral 2026-09-29
Deferred past v0.11.1: lane drainG3 (run A, 2026-09-28) built the parameter-expansion reading of iss-2609251824244354 and measured it: reading a variable's carried value as a pid list, a stream path, shell text or a pkill/killall program name refuses ordinary commands (22 program-name and 15 stream blocks in the 4,315-input sweep), and leaving it unread keeps each spelling open; whether each class blocks, warns or allows is a product ruling not yet made, so these four classes, and only they, are carried past v0.11.1. The ruling is still owed: the product thinker has not ruled on any of the four classes since the v0.11.0 deferral, which lapsed when v0.11.1 was cut.
Remedy grounds (2026-09-29)
- Why: ruling AT sets out options (a) to (d) and is unanswered, so the remedy gives the change and proof for each without picking; the sweep counts on this record are the cost evidence the ruling needs.
- Sources (consulted 2026-09-29): the Claude Code sandboxing documentation calls command rules and operating-system isolation 'complementary layers' (https://code.claude.com/docs/en/sandboxing): a static reading of a command line cannot know a variable's runtime value, so the field bounds what such a reading promises instead of chasing values.
- Rejected: taint tracking across lines, which the per-line reader cannot do and no shell guard in the field claims to do.
Frontmatter
| id | iss-2609281134544802 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-2609281134544802-a-variable-s-carried-value-is-not-read.md
open on GitHub ↗ · commit history ↗
mentions7
- adr-2610091150447054The guard reads a script the command names before it judges the 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-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.
- dcn-26093000000000282026-09-30 — Release v0.12.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.12.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.12.0, impact breaking (three breaking records, iss-2609251324599468, iss-2609291313276243 and iss-2609292359485570, and five removed command spellings the release guard found and the records declare), 281 records since v0.11.1: eleven shipped intents, all additive, and 270 resolved or declined issues (171 fixes, nineteen additive, three breaking, 77 internal and outside the changelog); the release guard passed, and the findings guard passed with the one major carried past v0.11.1 on its recorded deferral (iss-2609281134544802). Content commit fecdbed5, on top of 4b8ff2afa, e8d5d006, 4d634c4f and c8ddb042, which the gates' findings required. Both semantic gates ran at tier full over the first roll b89784c4, which differs from the content commit only by those four commits and the re-cut CHANGELOG. The docs-currency-reviewer (Fable 5.1) found 24, four major (a schema version the CHANGELOG and the upgrade guide gave as 4 where the binary writes 8, a build page's key shape, and a timeout the CHANGELOG gave to three hook entries where only UserPromptSubmit declares it), all four fixed; eleven in all are fixed in 4b8ff2afa and the re-cut CHANGELOG, and the thirteen remaining minors and nitpicks are captured as one documentation record (iss-2609302306281487). The brief-surface cross-check (45 pinned checkers, Opus 5.5, at most eight alive) found 129, all valid at b89784c4 on an independent classification (Fable 5.1): 39 cycle, 87 standing, three user-facing and four behaviour. The three user-facing findings are fixed in 4b8ff2afa, and every finding on the guard chapter 17-guard.md in e8d5d006, 4d634c4f and c8ddb042, after three docs-review holds; the docs review of c8ddb042 is PROMOTE. One brief sentence (17-guard.md:426) was applied from the reviewer's draft and is flagged for the product thinker's review in .abcd/work/brief-review-flags.json. Captured as four records, all minor and all to be fixed in the first lane after the tag: a capture refusal that leaves .iss-alloc.lock behind (iss-2609302305500526, x-025), the appendix generator labelling /abcd:version host-delegated (iss-2609302306003610, x-039 and x-118), the guard's trailing-dot fold that its comment promises and `rm -rf ../.` does not make (iss-2609302306019245), and `docs fidelity` record and --apply disagreeing on a verdict's chapter shape (iss-2609302306153318). The behaviour finding x-001 is not a defect: bare `ahoy remote` listing its sub-verbs and exiting 0 is the stated behaviour of v0.12.0's breaking change, so the stale side is the shipped intent's criterion; it and the rest of the design-record drift go to the systematic brief pass iss-2609091956001547. Integration branch 24d, reviewed and ready, holds until the tag and falls into the next release.
- iss-2609251824244354The 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.
- iss-2610112214257996Two spellings of a kill by name that iss-2609262259360005 named stay unread after ruling AS (product thinker, 2026-10-11) settled its third, the kill by the holder of a port or file: (1) a pid list carried through a file across commands (pgrep make > p; xargs kill < p), a data flow between commands the per-line reading does not follow; and (2) a pid list taken from a ps | grep | awk chain, where reading ps or grep as a search source would also block ps -o pid= -p N | xargs kill, the everyday kill of a recorded pid. The ruling as relayed to lane G2a named only the holder class, so whether these two block is still owed. A pid list carried through a variable is iss-2609281134544802.
- iss-2611060810026397`.abcd/development/brief/04-surfaces/17-guard.md:1044`: The chapter lists same-line carried values as unseen and calls the matter an open ruling, but they block and the ruling is made. All block when the value is assigned on the line (`X='rm -rf /'; eval "$X"`, `P=pkill; $P make`). The ruling is made: internal/core/guard/carried.go cites it, and iss-2609281134544802 is in .abcd/work/issues/resolved/.