From 4e668e19371bc10669eb380f7433ee29e132240c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:01:20 +0000 Subject: [PATCH 1/5] chore: capture four bugs verified by the bug-hunt loop (round 1) iss-184 (critical, guard heredoc arithmetic-shift bypass), iss-185 (critical, scanner adjacent-secret boundary bypass), iss-186 (minor, capture transition remove-failure strands an issue id), iss-187 (minor, rules.Merge panics on a nil-Domains base). Each carries a reproducing test verified independently before capture. This PR fixes iss-184; the other three remain open for a future round. Assisted-by: Claude:claude-opus-5 --- .../iss-184-guard-heredoc-arithmetic-shift-bypass.md | 12 ++++++++++++ ...ss-185-scanner-adjacent-secret-boundary-bypass.md | 12 ++++++++++++ ...apture-transition-remove-failure-strands-issue.md | 12 ++++++++++++ .../open/iss-187-rules-merge-nil-domains-panic.md | 12 ++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 .abcd/work/issues/open/iss-184-guard-heredoc-arithmetic-shift-bypass.md create mode 100644 .abcd/work/issues/open/iss-185-scanner-adjacent-secret-boundary-bypass.md create mode 100644 .abcd/work/issues/open/iss-186-capture-transition-remove-failure-strands-issue.md create mode 100644 .abcd/work/issues/open/iss-187-rules-merge-nil-domains-panic.md diff --git a/.abcd/work/issues/open/iss-184-guard-heredoc-arithmetic-shift-bypass.md b/.abcd/work/issues/open/iss-184-guard-heredoc-arithmetic-shift-bypass.md new file mode 100644 index 00000000..9100d666 --- /dev/null +++ b/.abcd/work/issues/open/iss-184-guard-heredoc-arithmetic-shift-bypass.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-184" +slug: "guard-heredoc-arithmetic-shift-bypass" +severity: "critical" +category: "bug" +source: "agent-finding" +found_during: "bug-hunt loop round 1" +found_at: "internal/core/guard/tokenize.go:152" +--- + +guard tokenizer heredoc misparse: an unquoted arithmetic left-shift with an identifier operand (e.g. `$((1< Date: Wed, 5 Aug 2026 12:07:54 +0000 Subject: [PATCH 2/5] fix: the guard fails loud, not silent, when a `<<` is not a heredoc (iss-184) An unquoted arithmetic left shift with an identifier operand ($((1< 0 && !lastList { - i = skipHeredocBodies(line, i, pending) + next, ok := skipHeredocBodies(line, i, pending) + if !ok { + return nil, fmt.Errorf("%w: unterminated here-document body", ErrUnparsableCommand) + } + i = next pending = nil } // lastList is NOT cleared here: a blank or comment-only line after a @@ -259,10 +263,17 @@ func readHeredocDelim(line string, pos int) (heredoc, int, error) { // skipHeredocBodies consumes the body of every pending here-document, starting // at pos (the first byte after the newline that ended the command line), and -// returns the position just past the last body. An unterminated body swallows -// the rest of the input — exactly as a shell would treat it. -func skipHeredocBodies(line string, pos int, pending []heredoc) int { +// returns the position just past the last body. The second return is false if +// any body never finds its terminating delimiter line before the input ends — +// which is either a genuinely truncated heredoc, or a `<<` that isDelimStart +// mistook for one (an identifier-operand arithmetic shift, `$((1< Date: Wed, 5 Aug 2026 12:08:03 +0000 Subject: [PATCH 3/5] docs: resolve iss-184 in the ledger and record the round (bug-hunt loop) Moves iss-184 open -> resolved via abcd capture resolve (impact: fix). DECISIONS.md gets one dated round-summary line for the bug-hunt loop's first round, naming the fix and the three bugs captured but not fixed this round (iss-185, iss-186, iss-187). CHANGELOG gains the user-facing Fixed entry. Assisted-by: Claude:claude-opus-5 --- .abcd/work/DECISIONS.md | 1 + .../iss-184-guard-heredoc-arithmetic-shift-bypass.md | 2 ++ CHANGELOG.md | 10 ++++++++++ 3 files changed, 13 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-184-guard-heredoc-arithmetic-shift-bypass.md (65%) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index d9c5b87e..b94d6719 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -920,3 +920,4 @@ parallel-agent merge contention bites. - 2026-08-05 — iss-80 (v0.5.0): resolved as already-fixed, with the residual verification gap closed rather than the defect re-fixed. The branch-local id-collision class this item tracked — `itd-N`/`spc-N`/`iss-N` allocators scanning only the working tree, so two agents on branches cut from one base each mint the same id and collide at merge — was already closed by iss-115 and iss-120, which introduced `recordid.MaxAcrossRefs` (scan every git ref for the family's highest committed id) and folded it into all three minting paths: `internal/core/spec/store.go`, `internal/core/intent/create.go`, and `internal/core/capture/workflow.go`. What survived was uneven evidence, not an uneven fix: only the `spc-N` family had an end-to-end regression test standing up a real two-branch history (`internal/core/spec/refunion_test.go`), while none of capture's or intent's existing tests stood up a real two-branch history through `Capture`/`CreateFromText` and so never drove the ref scan through their public entry points at all — `MaxAcrossRefs` was exercised only as a primitive in its own package test, never as wiring. That is the shape in which a fix silently un-wires: a refactor that dropped the `MaxAcrossRefs` call from `Capture` or `CreateFromText` would have left every existing test in both packages green. So this round adds `internal/core/capture/refunion_test.go` (`TestCaptureMintsPastACommittedBranch`) and `internal/core/intent/refunion_test.go` (`TestCreateFromTextMintsPastACommittedBranch`), each mirroring the spec test's exact fixture via `internal/gittest.NewRepo` — branch A mints and commits record 1, branch B is cut from before that commit so its working tree carries no record, and B must mint record 2. Both were mutation-checked before being trusted: with `MaxAcrossRefs` stubbed to return an empty `RefScan`, each fails with precisely the collision it names (`iss-1`/`itd-1` re-minted on branch B), and all three families fail together — so the tests are pinned to the ref-union behaviour rather than passing incidentally. No production code was touched, which is the point: the minting paths were verified correct by reading before the coverage was written, and the change is test-and-record only. Impact recorded as `internal` and no CHANGELOG entry, matching the iss-34 precedent for a test-only closure with no user-facing behaviour change. - 2026-08-05 — CORRECTION to the entry immediately above, from a merge-gate record-accuracy review on PR #195. That entry, and iss-80's own `resolution:` field, said the branch-local id-collision class "was closed" by iss-115/iss-120. It was not: `internal/core/recordid/recordid.go`'s own package doc documents an ACCEPTED, undocumented-in-that-entry residual window — two branches that BOTH mint before either commits still collide — left to the already-armed record-lint uniqueness detectors (`issue_id_unique`, `intent_lifecycle`, `spec_id_unique`) as backstop, the same trade-off iss-120's own resolution names verbatim ("the armed record-lint detectors as the residual-window backstop"). iss-80's body asked for a fully collision-free minting scheme (forge-minted / random-suffix / timestamp / reserve-registry); that was never built, that trade-off was made when iss-115/iss-120 landed rather than here, and no open item currently tracks the residual window separately. Both iss-80's `resolution:` field and this log now say "already addressed, not closed to zero" and name the trade-off explicitly, rather than implying the class was eliminated. The test-coverage work in the entry above (the two new refunion tests) is unaffected and stands as described. - 2026-08-05 — iss-43 (v0.5.0 item D): the three-claim corpus is closed as OVERTAKEN rather than fixed — the Status section and the surface list were removed by `73428b6` (2026-07-17, "manual: Revise README with new badges and project details"), a manual revision predating the v0.5.0 plan, so the Phase 0 claim and the native review oracle / spec-task engine / autonomous run claims no longer exist to correct, and re-deriving a corpus against a rewritten document would be inventing findings to justify a ticket. That attribution is itself a correction worth recording: the round first credited the iss-143 tagline commit `48a3524`, which touches ONE README line (the strapline), because it read a SHALLOW clone whose truncation window opened well after 2026-07-17 and therefore could not see the real commit — a truncated history answers a `git log` question confidently and wrongly, and the cheap guard is `git log -S` over the file for the exact removed string, unshallowed first. Two corollaries earned the same way: the corpus's third claim was never `73428b6`'s at all (`git log -S "never shipped"` puts that line in the scaffold commit and in this branch alone), so "all three overtaken" was a second wrong attribution riding on the first; and a whole-phrase probe can miss a claim that was line-wrapped in the original, which is why `"spec/task"` reproduces where `"spec/task engine"` returns nothing. One instance survives, and it is half true rather than false, which is why it needed splitting instead of deleting: the Layout line called `.abcd/` "never shipped", which holds for the released binaries alone (release.yml uploads the four of them and `checksums.txt`, nothing else) and fails everywhere else — `.claude-plugin/marketplace.json` declares `source: "./"`, so a marketplace install takes the whole repository, and GitHub attaches auto-generated source archives to each release, which carry the directory too because `.gitattributes` declares no `export-ignore`. The line now divides on the boundary that is real: every repository checkout against the released binaries, rather than a per-channel split that would have been wrong about the release page. Doc-side deliberately: the marketplace manifest schema has no per-path exclusion, so making "never shipped" true everywhere means building a curated publish path — feature work with its own design questions, not doc repair, and writing the doc to describe a packaging mechanism that does not exist is the phantom-claim failure this item belongs to. The proposed detector (README capability and status claims resolve to wired verbs and to the roadmap) is DROPPED here and re-pointed at iss-181 as a candidate extension of the gate cross-check detector's scope, recorded as a candidate rather than a commitment because it shares that detector's two unsettled scoping questions and a second half-specified scanner would answer neither. The same claim survives across the record — CONTEXT.md's sharp-edges list, AGENTS.md, both `.abcd/` READMEs, `02-constraints/01-platform.md`, `05-internals/03-configuration.md` and `phase-1-ahoy.md`, with adr-0028, the dated plans and the planned/shipped intent bodies exempt as decision and historical records — and it is recorded as iss-183 rather than fixed here, since the disposition scoped this item to one README line. That issue also corrects a claim this round made twice: the exclusion is NOT unimplemented. `internal/core/launch/bundle.go` denies the `.abcd` namespace structurally, with tests, reachable from the wired `abcd launch ship`; it is unwired, because `Ship` stops at `WouldPublish` with no network call and `release.yml` uploads the binaries without ever invoking the verb. "No mechanism exists" and "the mechanism never runs" ask for different fixes, and only the second is true. +- 2026-08-05 — bug-hunt loop round 1: iss-184 (critical, guard tokenizer heredoc misparse) fixed at root cause. `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< Date: Wed, 5 Aug 2026 12:19:38 +0000 Subject: [PATCH 4/5] fix: a heredoc delimiter can't be adjacent to a bare paren (iss-184) The pre-PR security review found that the first pass only closed the unterminated half of the heredoc-vs-arithmetic-shift confusion: an attacker who supplies a later line matching the misread "delimiter" (e.g. appending a bare `shift`) still finds a coincidental terminator, so the guarded command is still silently swallowed with no error. Root cause: a real heredoc delimiter word is never immediately followed by a bare `(` or `)` with no separator -- its body and terminator line have to come first. `$((expr< Date: Wed, 5 Aug 2026 12:19:45 +0000 Subject: [PATCH 5/5] docs: correct the iss-184 record to describe the real fix The ledger resolution, DECISIONS.md round line, and CHANGELOG entry described the first pass's error-on-unterminated-body mitigation as a root-cause fix. It wasn't -- the pre-PR security review demonstrated a live bypass surviving it. All three now describe the two-pass fix accurately: the classification fix that actually closes the hole, and the unterminated-body fix kept as defense in depth for a distinct, narrower gap. Assisted-by: Claude:claude-opus-5 --- .abcd/work/DECISIONS.md | 2 +- ...4-guard-heredoc-arithmetic-shift-bypass.md | 2 +- CHANGELOG.md | 26 ++++++++++++------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index b94d6719..bc85b723 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -920,4 +920,4 @@ parallel-agent merge contention bites. - 2026-08-05 — iss-80 (v0.5.0): resolved as already-fixed, with the residual verification gap closed rather than the defect re-fixed. The branch-local id-collision class this item tracked — `itd-N`/`spc-N`/`iss-N` allocators scanning only the working tree, so two agents on branches cut from one base each mint the same id and collide at merge — was already closed by iss-115 and iss-120, which introduced `recordid.MaxAcrossRefs` (scan every git ref for the family's highest committed id) and folded it into all three minting paths: `internal/core/spec/store.go`, `internal/core/intent/create.go`, and `internal/core/capture/workflow.go`. What survived was uneven evidence, not an uneven fix: only the `spc-N` family had an end-to-end regression test standing up a real two-branch history (`internal/core/spec/refunion_test.go`), while none of capture's or intent's existing tests stood up a real two-branch history through `Capture`/`CreateFromText` and so never drove the ref scan through their public entry points at all — `MaxAcrossRefs` was exercised only as a primitive in its own package test, never as wiring. That is the shape in which a fix silently un-wires: a refactor that dropped the `MaxAcrossRefs` call from `Capture` or `CreateFromText` would have left every existing test in both packages green. So this round adds `internal/core/capture/refunion_test.go` (`TestCaptureMintsPastACommittedBranch`) and `internal/core/intent/refunion_test.go` (`TestCreateFromTextMintsPastACommittedBranch`), each mirroring the spec test's exact fixture via `internal/gittest.NewRepo` — branch A mints and commits record 1, branch B is cut from before that commit so its working tree carries no record, and B must mint record 2. Both were mutation-checked before being trusted: with `MaxAcrossRefs` stubbed to return an empty `RefScan`, each fails with precisely the collision it names (`iss-1`/`itd-1` re-minted on branch B), and all three families fail together — so the tests are pinned to the ref-union behaviour rather than passing incidentally. No production code was touched, which is the point: the minting paths were verified correct by reading before the coverage was written, and the change is test-and-record only. Impact recorded as `internal` and no CHANGELOG entry, matching the iss-34 precedent for a test-only closure with no user-facing behaviour change. - 2026-08-05 — CORRECTION to the entry immediately above, from a merge-gate record-accuracy review on PR #195. That entry, and iss-80's own `resolution:` field, said the branch-local id-collision class "was closed" by iss-115/iss-120. It was not: `internal/core/recordid/recordid.go`'s own package doc documents an ACCEPTED, undocumented-in-that-entry residual window — two branches that BOTH mint before either commits still collide — left to the already-armed record-lint uniqueness detectors (`issue_id_unique`, `intent_lifecycle`, `spec_id_unique`) as backstop, the same trade-off iss-120's own resolution names verbatim ("the armed record-lint detectors as the residual-window backstop"). iss-80's body asked for a fully collision-free minting scheme (forge-minted / random-suffix / timestamp / reserve-registry); that was never built, that trade-off was made when iss-115/iss-120 landed rather than here, and no open item currently tracks the residual window separately. Both iss-80's `resolution:` field and this log now say "already addressed, not closed to zero" and name the trade-off explicitly, rather than implying the class was eliminated. The test-coverage work in the entry above (the two new refunion tests) is unaffected and stands as described. - 2026-08-05 — iss-43 (v0.5.0 item D): the three-claim corpus is closed as OVERTAKEN rather than fixed — the Status section and the surface list were removed by `73428b6` (2026-07-17, "manual: Revise README with new badges and project details"), a manual revision predating the v0.5.0 plan, so the Phase 0 claim and the native review oracle / spec-task engine / autonomous run claims no longer exist to correct, and re-deriving a corpus against a rewritten document would be inventing findings to justify a ticket. That attribution is itself a correction worth recording: the round first credited the iss-143 tagline commit `48a3524`, which touches ONE README line (the strapline), because it read a SHALLOW clone whose truncation window opened well after 2026-07-17 and therefore could not see the real commit — a truncated history answers a `git log` question confidently and wrongly, and the cheap guard is `git log -S` over the file for the exact removed string, unshallowed first. Two corollaries earned the same way: the corpus's third claim was never `73428b6`'s at all (`git log -S "never shipped"` puts that line in the scaffold commit and in this branch alone), so "all three overtaken" was a second wrong attribution riding on the first; and a whole-phrase probe can miss a claim that was line-wrapped in the original, which is why `"spec/task"` reproduces where `"spec/task engine"` returns nothing. One instance survives, and it is half true rather than false, which is why it needed splitting instead of deleting: the Layout line called `.abcd/` "never shipped", which holds for the released binaries alone (release.yml uploads the four of them and `checksums.txt`, nothing else) and fails everywhere else — `.claude-plugin/marketplace.json` declares `source: "./"`, so a marketplace install takes the whole repository, and GitHub attaches auto-generated source archives to each release, which carry the directory too because `.gitattributes` declares no `export-ignore`. The line now divides on the boundary that is real: every repository checkout against the released binaries, rather than a per-channel split that would have been wrong about the release page. Doc-side deliberately: the marketplace manifest schema has no per-path exclusion, so making "never shipped" true everywhere means building a curated publish path — feature work with its own design questions, not doc repair, and writing the doc to describe a packaging mechanism that does not exist is the phantom-claim failure this item belongs to. The proposed detector (README capability and status claims resolve to wired verbs and to the roadmap) is DROPPED here and re-pointed at iss-181 as a candidate extension of the gate cross-check detector's scope, recorded as a candidate rather than a commitment because it shares that detector's two unsettled scoping questions and a second half-specified scanner would answer neither. The same claim survives across the record — CONTEXT.md's sharp-edges list, AGENTS.md, both `.abcd/` READMEs, `02-constraints/01-platform.md`, `05-internals/03-configuration.md` and `phase-1-ahoy.md`, with adr-0028, the dated plans and the planned/shipped intent bodies exempt as decision and historical records — and it is recorded as iss-183 rather than fixed here, since the disposition scoped this item to one README line. That issue also corrects a claim this round made twice: the exclusion is NOT unimplemented. `internal/core/launch/bundle.go` denies the `.abcd` namespace structurally, with tests, reachable from the wired `abcd launch ship`; it is unwired, because `Ship` stops at `WouldPublish` with no network call and `release.yml` uploads the binaries without ever invoking the verb. "No mechanism exists" and "the mechanism never runs" ask for different fixes, and only the second is true. -- 2026-08-05 — bug-hunt loop round 1: iss-184 (critical, guard tokenizer heredoc misparse) fixed at root cause. `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<