Development record
2026-08-08 — bug-hunt loop round 9 (state issue #197): a multi-angle sweep (5
2026-08-08 — bug-hunt loop round 9 (state issue #197): a multi-angle sweep (5
blind hunt angles plus independent adversarial verification) surfaced a
complete, silent bypass of every guard blocker via env -S/--split-string
(GNU env re-splits and runs that value as the command; the guard's wrapper
table treated it as the wrapper's own value and stepped past it). A fix
attempt (branch bugfix/env-split-string-guard-bypass, closing the
separate-token spelling env -S git push ...) was BLOCKed at pre-PR review by
two independent reviewers (correctness and security), who each independently
found the fix incomplete: the glued spellings env -Sgit ... and
env --split-string=git ... execute identically to the closed form and
remained a live bypass. Per protocol this is a first-attempt BLOCK, not a
repeat of the unrelated iss-189/190 scanner root cause, so it stops here
rather than escalating — nothing from that branch was pushed. The complete bug
(all three spellings, plus the reviewers' converging suggested repair —
handle env's -S<value>/--split-string=<value> forms alongside the
separate-token form in commandOf/skipWrapperArgs, no nested re-tokenizing
needed) is captured fresh as iss-200, since iss-196 (the narrower, incomplete
framing) only ever existed on the abandoned, unpushed branch and was never
merged; treat iss-196 as superseded by iss-200 the way iss-191 was re-scoped
into iss-195.
The same sweep also confirmed three further bugs, captured here record-only
(no behaviour changes in this commit): iss-201 (abcd guard hook's stdin read
has no overflow check, so a payload padded past the 1 MiB cap silently
truncates, fails JSON parsing, and fail-opens with a diagnostic that
misleadingly blames the host instead of reporting the cap overflow — major,
not critical, since the practical exploitation cost is a single tool_input
exceeding roughly 1 MiB of model-generated content, and the fail-open is loud,
never silent); iss-202 (scanner.New reads .abcd/config/pii.json with a
bare, unguarded os.ReadFile — no symlink guard, no size cap — unlike every
sibling trust-boundary config reader in this codebase; a FIFO hangs it forever
and a git-committable symlink to a device file grows it toward OOM, both
reachable automatically via the SessionEnd hook, and the hang also wedges
history.repoLock's unbounded flock, permanently disabling transcript
capture for that repo — the same bug class already fixed once under iss-97,
which this instance escaped); and iss-203 (abcd audit's privacy rule guards
on scanner.New's error return expecting a fallback to the built-in pattern
set, but scanner.New never returns a non-nil error, so the guard is always
true, the documented fallback is dead code, and a broken pii.json override
silently drops a repo's raised severities and downgrades the audit exit code
from 2 to 1 with no diagnostic).
Also reconfirmed this round (captured round 7 as iss-195, previously flagged
as needing adversarial verification before fix-eligibility): the scanner's
rigid/open-ended heuristic false-positive hard_fail-flags RFC 3849 reserved
documentation addresses and silently corrupts them via Redact, on top of
the previously-known cost regression (up to ~54x on colon-hex content, worse
than the ledger's original 1.9x-27x table) — worse than its minor label
suggests. Its own fix is out of scope for this round, but iss-195 is a
distinct bug in the scanner's rigid/open-ended heuristic, not part of the
shelved scanner adjacency-recovery mechanism (iss-189/190/191, shelved per
the maintainer's 2026-08-07 ruling); iss-195 itself remains fix-eligible, not
shelved. No ledger or code change made for iss-195 this round beyond this
reconfirmation note.
Frontmatter
| id | dcn-2608080000000002 |
|---|---|
| type | decision |
| date | 2026-08-08 |
.abcd/work/decisions/dcn-2608080000000002-bug-hunt-loop-round-9-state-issue-197-a.md
open on GitHub ↗ · commit history ↗
mentions8
- iss-97ahoy.Detect reads CLAUDE.md/AGENTS.md/config via plain os.ReadFile with no size cap or O_NONBLOCK, unlike the hardened session-end transcript read; a FIFO or multi-GB file at cwd could hang or slurp any hook that calls Detect (session-start, session-end, ahoy verbs). Harden Detect's marker/config reads to match maxTranscriptBytes plus a non-blocking open.
- iss-189adjacency probe's trailing \b can be satisfied by the window edge instead of real content, causing a spurious over-redaction. scanAllPatterns (scanner.go) slices the line to a bounded window (line[end:min(end+512,len(line))]) before running each pattern's adjacencyProbe, to keep one probe attempt's cost constant regardless of what follows (iss-185's fix). Some probes retain an internal trailing \b of their own (e.g. net_lan_hostname's `\b` at the very end of its alternation) which normally requires a real non-word character (or true end of string) after the match. But when the probe is truncated to the window, "end of window" is treated by the regex engine exactly like "end of string" — so if the window happens to end immediately after what LOOKS like a complete hostname/token, the trailing \b is satisfied by the artificial window boundary rather than by real surrounding content, even though the FULL, untruncated line would not have matched there (the real next byte continues the token). Concrete repro: a line built as `"AKIA"+16*"Q"` (a confirmed match, establishing the probe's junction) + 506 filler bytes + ".local" + "zzz" — the window (512 bytes from the junction) ends exactly after "local", so net_lan_hostname's probe matches "b...local" (satisfied by end-of-window) even though the real line continues with "zzz" right after ".local", meaning the FULL line's own un-windowed net_lan_hostname pattern would NOT match there at all (no real word boundary follows in the actual text). Impact: a spurious net:lan_hostname finding (warn severity only, not hard_fail) causes Redact to needlessly rewrite up to 512 bytes of legitimate content as fingerprinted/starred output — not a security leak (over-redaction, not under-redaction) but a correctness/usability defect: legitimate file content gets mangled on a contrived but reachable input shape. Suggested fix (from the reviewer that found this): when a probe match's end lands exactly at the window's limit AND limit < len(line) (i.e. the window was actually truncated, not naturally at the line's end), re-run that one probe against the full, untruncated line[end:] before accepting the match — paying the larger cost only in this rare edge case rather than on every probe. Found during a merge-gate correctness review of iss-185's PR (bugfix/iss-185-scanner-adjacent-secret-boundary-bypass); non-blocking (contrived input, warn-severity-only impact), so not fixed inline — captured for its own verify-and-fix cycle.
- iss-191junctionProbe's compile fallback turns stolenJunctions from a bounded candidate walk into a per-byte one, a latent cost cliff. junctionProbe (internal/adapter/scanner/scanner.go) builds one combined alternation over every pattern's boundary-free body; if that alternation fails to compile it falls back to regexp.MustCompile(`(?s).`), which matches at EVERY byte offset. The fallback was chosen so the candidate generator can over-produce but never under-produce — correct for detection, but it changes stolenJunctions' cost class: instead of one wholeMatch validation per real candidate junction in the backtrack window, wholeMatch runs once per byte offset in the window, and each of those validations is itself proportional to the match's own length (it re-runs the anchored probe over line[m.start:cut]). Cost per match goes from O(window) validations to O(window x match length) work. A reviewer measured roughly a 500x slowdown substituting the fallback directly — 4.21s against 8.25ms on one 200KB match. Not reachable with the current bundled pattern set: every bundled body alternates cleanly, and triggering the compile failure needs a pathological custom .abcd/config/pii.json override whose added pattern makes the joined alternation invalid (the per-pattern regexes are each compiled and validated on merge, so this is hard but not provably impossible to reach). Latent rather than urgent. Options if it is ever worth closing: cap the fallback's candidate count the way the window caps the search, drop patterns from the alternation one at a time until it compiles rather than abandoning the whole set, or mark the scanner unavailable (fail-closed) when the combined probe cannot be built — the last is consistent with how the package already treats a config fault.
- iss-195The scanner's rigid/open-ended heuristic sends every IPv4 and IPv6 match through stolenJunctions' backward search, a measurable cost regression on ordinary log content. stolenJunctions (internal/adapter/scanner/scanner.go) skips its bounded backward search only for patterns whose match cannot be one byte shorter; net_ipv4 and net_ipv6 (internal/adapter/scanner/network.go) are variable-length, so every address they match in ordinary content enters the search — no custom config, no compile-failure fallback needed (that latter, narrower mechanism remains iss-191's scope and still does not trigger on the bundled set, whose combined alternation compiles cleanly). Measured on identical content, current main (bddb6fa) vs the tree immediately before the iss-188 machinery (eb61acd), 100-line ~12KB documents, all identifiers reserved documentation values (RFC 3849/5737/7042): neutral content 1.0x; one MAC per line 3.6x slower (net_mac itself classifies rigid, but ipv6Re also matches MAC-shaped colon-hex and that overlapping match is open-ended); one compressed IPv6 per line 1.9x; one MD5-style SSH fingerprint per line 23x; one dense colon-hex known_hosts-style line 27x. Because Skip/SkipAt reserved-range filtering runs AFTER scanAllPatterns, reserved addresses that are never reported still pay the full search cost. The iss-188 review also demonstrated a correctness angle on this same path: duplicate hard_fail findings manufactured for a single compressed IPv6, reproduced on this repo's own tracked fixtures (internal/core/memory/memory_test.go, internal/urlguard/urlguard_test.go). Candidate directions: exempt network-class patterns from the backward search where a stolen-junction leak is not plausible for them, or short-circuit when the only patterns whose bodies can match in the backtrack window are network-class; whether the residual cost is then a documented trade-off is a maintainer call. Split out of iss-191 per maintainer ruling (2026-08-07); benchmark method and full table recorded here because the scratch artefacts under .abcd/.work.local/ are ephemeral.
- iss-200guard's env wrapper table listed -S/--split-string as a value-owning flag for the SEPARATE-TOKEN spelling only ('env -S git push --force origin main'), but GNU env -S/--split-string also accepts the value glued to the flag ('env -Sgit push --force origin main', 'env --split-string=git push --force origin main'), which re-splits identically and executes identically; a fix attempted in bug-hunt loop round 9 (bugfix/env-split-string-guard-bypass) closed only the separate-token form and was BLOCKed at pre-PR review by both an independent correctness reviewer and an independent security reviewer, who each independently reproduced that the glued forms remain a complete, silent bypass of every guard blocker on the live PreToolUse path; suggested remediation (from both reviewers, converging independently): in commandOf/skipWrapperArgs, treat -S/--split-string as a value-owning env flag in all three spellings — the exact separate-token form (-S as its own token, value in the following token), the glued form (-S<value>, len>2), and the --split-string=<value> form (value non-empty) — and in every case read the first word of <value> as command position, rather than either treating the whole glued token as a consumed wrapper flag or skipping the following token outright in the separate-token case — no nested re-tokenizing required, contained to the same two functions
- iss-201abcd guard hook reads its PreToolUse stdin payload via a bare LimitReader with no overflow check, so a payload padded past the 1 MiB cap (e.g. a blocked command with a trailing shell comment full of junk) is silently truncated, breaks JSON parsing, and routes to the fail-open-loud path (exit 1, command runs) with a diagnostic that misleadingly blames the host ('not readable JSON') instead of reporting the actual cap overflow; the sibling check verb's guardCandidate already reads cap+1 specifically to detect and refuse this. Independently verified: threshold is sharp and deterministic (cap-1 blocks, cap+1 fails open), always a loud (not silent) fail-open, exploitation cost bounded by needing >1 MiB of attacker-influenced content inside one tool_input
- iss-202scanner.New reads the per-repo .abcd/config/pii.json override with a bare os.ReadFile — no O_NOFOLLOW, no size cap, no regular-file check — unlike every sibling trust-boundary config reader in this codebase (guard.Load, rules.Load, banlist private store) which goes through fsutil.ReadGuarded; a FIFO planted at that path hangs the read forever and a symlink to a device file (git-committable, mode 120000, no local write access needed) grows the read unbounded toward OOM; reachable automatically via the SessionEnd hook's history capture, which also holds an unbounded history.repoLock flock across the hang, permanently wedging transcript capture for that repo; this is the same bug class already fixed once for ahoy.Detect under iss-97. Independently verified: FIFO hangs past 3s deadline (control: no-config-file case returns in 0.01s), /dev/zero symlink accumulates ~137 MiB/s, causal fix test (swap to fsutil.ReadGuarded) confirmed both cases resolve
- iss-203audit's privacy-hygiene rule guards on 'if sc, err := scanner.New(ctx.RepoRoot); err == nil' expecting a fallback to the built-in pattern set on error, but scanner.New never returns a non-nil error — every degradation path (unreadable/unparseable/uncompilable pii.json override) returns unavailable=true with a nil error instead — so the guard is always true, the documented fallback branch is dead code, and the rule never calls sc.Unavailable(); a broken per-repo severity override (malformed JSON, a directory at the config path, or an uncompilable override regex) silently drops the repo's raised severities and downgrades the audit exit code from 2 to 1 with zero finding or diagnostic explaining why, unlike history.Capture and the CLI identity path which both check Unavailable() explicitly on the same scanner. Independently verified via two-fixture test (valid override raises severity to error/exit-2; broken override silently drops to warn/exit-1 with no diagnostic) for all three degradation branches (malformed JSON, directory at path, uncompilable regex)