Development record
guard tokenizer heredoc misparse: an unquoted arithmetic left-shift with an identifier operand (e.g. `$((1<<shift))`) is misparsed as a here-document start by isDelimStart (tokenize.go:206, accepts any word starting with a letter/underscore) plus readHeredocDelim (tokenize.go:249, terminates the delimiter word on ')'). skipHeredocBodies (tokenize.go:264) then scans for a line equal to the bogus delimiter, finds none, and silently swallows every subsequent line of the command before it reaches command position. A dangerous command on a later line (git push --force, rm -rf, gh repo delete, etc.) is never seen by Registry.Check and the guard returns VerdictAllow with no error — a silent fail-open, not the loud could-not-answer path the shim reserves for unparsable input. Confirmed live via both CLI front doors (internal/surface/cli/guard.go: guard check and guard hook). The literal-digit form (1<<20) is unaffected; only identifier/variable operands trip it. Reproducing test: internal/core/guard/tokenize_test.go, TestArithmeticShiftByIdentifierIsNotAHeredoc.
guard tokenizer heredoc misparse: an unquoted arithmetic left-shift with an identifier operand (e.g. $((1<<shift))) is misparsed as a here-document start by isDelimStart (tokenize.go:206, accepts any word starting with a letter/underscore) plus readHeredocDelim (tokenize.go:249, terminates the delimiter word on ')'). skipHeredocBodies (tokenize.go:264) then scans for a line equal to the bogus delimiter, finds none, and silently swallows every subsequent line of the command before it reaches command position. A dangerous command on a later line (git push --force, rm -rf, gh repo delete, etc.) is never seen by Registry.Check and the guard returns VerdictAllow with no error — a silent fail-open, not the loud could-not-answer path the shim reserves for unparsable input. Confirmed live via both CLI front doors (internal/surface/cli/guard.go: guard check and guard hook). The literal-digit form (1<<20) is unaffected; only identifier/variable operands trip it. Reproducing test: internal/core/guard/tokenize_test.go, TestArithmeticShiftByIdentifierIsNotAHeredoc.
Correction, 2026-09-29
The resolution was true when it was written, and its part (2) is not true at
this tip. 16d50feb ("fix: give the two bash-executable tokenizer states a
verdict") changed what a genuine heredoc with no terminator line does: it is no
longer ErrUnparsableCommand, because an error reaches the pre-tool-use hook as
fail-open, but a segment flag that Check folds into a fail-closed block. The
same commit replaced TestTokenizeRejectsUnterminatedHeredoc, which the
resolution names, with TestTokenizeFlagsUnterminatedHeredoc in
internal/core/guard/tokenize_test.go, which pins the block. Part (1), the
arithmetic-shift classification, stands as the resolution describes it, and
its two tests are still in the tree.
Frontmatter
| id | iss-184 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | critical |
.abcd/work/issues/resolved/iss-184-guard-heredoc-arithmetic-shift-bypass.md
open on GitHub ↗ · commit history ↗
mentions3
- dcn-26080500000000062026-08-05 — bug-hunt loop round 1: iss-184 (critical, guard tokenizer heredoc misparse) fixed at root cause, in two passes after a pre-PR adversarial security review caught the first pass narrowing the hole rather than closing it. `internal/core/guard/tokenize.go`'s `<<` handling already special-cased the literal-digit arithmetic-shift form (`$((1<<20))` is not a heredoc), but `isDelimStart` accepts any identifier-shaped word, so `$((1<<shift))` — an identifier operand — still read as a heredoc delimiter, and `skipHeredocBodies` then silently consumed every remaining line of the command looking for a "shift" terminator line that never comes, dropping later commands (a force-push, an `rm -rf`, anything) from ever reaching command position — a silent allow, not the loud fail-open the hook's own doc comment promises for anything the tokenizer cannot answer confidently. The first pass made `skipHeredocBodies` report whether it actually found each pending heredoc's terminator, turning a miss into `ErrUnparsableCommand`. The security review then constructed the exploit this missed: an attacker supplying a later line that happens to equal the misread "delimiter" (e.g. appending a bare `shift`) still finds a match, so the swallow still succeeds silently — the fix only closed the *unterminated* half of the class, not the *coincidentally-terminated* half, which is the one an attacker actually controls. The real fix is classification, not detection-of-failure: a heredoc delimiter word immediately followed by a bare `(` or `)` with no separator is never a real heredoc — its body and terminator line have to come first, so nothing legitimate places a paren directly against the delimiter word, and `$((expr<<ident))` produces exactly that shape. `<<` in that position is now read as the arithmetic operator at tokenize time, same as the literal-digit case, so the guarded line is reached and matched normally regardless of what any later line contains. The unterminated-body-to-error fix from the first pass is kept as defense in depth for genuinely malformed heredocs (`cat <<EOF` with no closing `EOF` line), a distinct, narrower gap the same swallow also covered — an existing test that pinned that silent-swallow as intended behaviour is updated to assert the corrected `ErrUnparsableCommand` contract. Detectors: `TestArithmeticShiftByIdentifierIsNotAHeredoc`, `TestArithmeticShiftCoincidentalDelimiterStillBlocks` (the adversarial payload the security review demonstrated), and `TestTokenizeRejectsUnterminatedHeredoc` — each watched failing on pre-fix code for the claimed reason and passing after. Three more bugs surfaced by the same round's multi-angle sweep and independently verified with failing tests are captured but not fixed here — iss-185 (critical, scanner leaves a second back-to-back secret token undetected and unredacted, defeating history's fail-closed residual re-scan), iss-186 (minor, a failed source-unlink in a capture ledger transition can permanently strand an issue id across two status directories), iss-187 (minor, `rules.Merge` panics on a nil-Domains base — currently unreachable through any live caller, so a latent exported-API contract defect) — left open for a future round, highest severity first.
- dcn-26080500000000082026-08-05 — iss-118: DECISIONS.md and ACKNOWLEDGEMENTS.md gain `merge=union` in `.gitattributes`, the same remedy already in place for CHANGELOG.md. Both are the identical shape — append-only ledgers of anonymous, dated, order-independent entries that never need identity — so a concurrent-append conflict has nothing to actually disagree on; the union driver keeps both sides instead of stopping the merge. Prompted directly by the bug-hunt loop's `bugfix/iss-184-…` branch hitting exactly this conflict against `.abcd/work/DECISIONS.md` on PR #199, which iss-118 had already diagnosed (filed after an earlier merge hit the same conflict) but left unresolved pending this design call. Full atomicisation (per-decision records, a new id family, an armed uniqueness detector) was the other option iss-118 named; not pursued here as disproportionate to a minor, low-traffic hotspot. Impact recorded as `internal`, no CHANGELOG entry — matching the iss-80 precedent for a process/tooling change with no user-facing behaviour.
- iss-2609261034583909Eight resolved records name, in their resolution notes, a test no longer in the tree; each was true when written, and RS006 reads only records entering a terminal folder, so none is refused, but a reader following the note finds nothing. Verified at b7646ff1, with the commit that removed each test: iss-184 names TestTokenizeRejectsUnterminatedHeredoc (16d50feb renamed it TestTokenizeFlagsUnterminatedHeredoc and changed the behaviour: an unterminated heredoc is a verdict, not ErrUnparsableCommand, so the note's part (2) is stale too); iss-2608301808193750 names TestIsAbsentValueIsASpellingTestNotANullTest (3eb4b549 replaced the spelling predicate with a class-based one, TestAbsenceIsDecidedByClassNotBySpelling, so the note's 'deliberately NOT widened' is superseded); iss-2608311632382737 names TestPreflightRunsBothEvalLanes (the test is TestPreflightRunsEveryTaggedEvalLane, which derives the lanes from the Makefile; the one clean rename of the eight); iss-2608311632439831 names TestComparativeRefusesToAssemble (3b62c967 made the comparative position assemble from the widening run's items, so the refusal the note describes no longer exists); iss-2609012039117381 names TestBinaryHooksFallBackToAPathBinary and iss-275 names TestGuardShimFallsBackToPATH (c637a734 removed the PATH rung those tests pinned: the shims run only the abcd the machine recorded); iss-354 names TestWorkflowGoVersionsMatchSubstitutions (removed in df377ee6, replaced by TestEverySetupGoResolvesTheToolchainFromGoMod under iss-2609090951291799); iss-2609090951291799 names TestWorkflowGoVersionsMatchSubstitutions only to say it was replaced, which stays accurate. Six of the eight need an amendment note stating what superseded the test and the claim, not a rename, so the set is not a mechanical fix.