Bug-hunt round 9: guarded memory reads, a fail-closed resolution gate, and validated lint severities - #521
Conversation
The store is an adjudicated trust boundary (ingest.go: a committed page symlink to /dev/zero would hang or OOM the CLI), and ingest's own reads go through fsutil.ReadGuarded — but every sibling reader kept a raw os.ReadFile: the bare status (readOrEmpty for index.md/contradictions.md with no type check, the headroom crawl, the coverage-index read), the lint typed-page gate and both its WalkDir sweeps (WalkDir yields symlinks as non-dir entries and the read follows them), ask's page reads (the ReadDir-to-open swap window ingest documents closing), the quotation-budget config, the stored fingerprint, the writer's read-back, and the ingest licence probes over an arbitrary source root. Reproduced as five distinct hangs via committed mode-120000 fixtures against abcd memory and abcd memory lint. Route them all through fsutil.ReadGuarded (O_NOFOLLOW + regular-file-on-fd + size cap) with the existing in-package caps, and skip non-regular entries in the three WalkDir crawls. Watched-fail tests plant symlinked store leaves and assert the content never crosses. Assisted-by: Claude:claude-fable-5
Three probes on the ledger arm swallowed git failures into a clean pass: cd "$(git rev-parse --show-toplevel)" collapses to a successful cd '' when the substitution fails under errexit; the ls-tree listing's || true turned a git failure into 'no ledger records — nothing to check', exit 0; and the shallow probe compared a failed substitution against 'true', so the exit-2 environment-fault arm was disarmed by the same fault it exists to report. Reproduced with git's dubious-ownership refusal — the reachable local form for containers, devcontainers and sudo — which turned 113-records-checked into OK exit 0, with RS003 the sole detector for resolution stamps a squash or rebase merge rewrites. The commits arm already failed closed (a bare assignment), so the divergence sat within one file, and the sibling gate check-reviews.sh ratified the rc-checked pattern for exactly this class. rc-check all four probes (the RS002 diff listing shared the || true shape), exit 2 with the git stderr surfaced, and keep the legitimate empty-ledger pass loud. Three new cases pin both fault shapes and the surviving clean pass; both fault cases fail against the old gate. Assisted-by: Claude:claude-fable-5
A rule whose severity was missing, misspelt, or off-enum emitted findings that printed but counted toward no exit code, so record-lint and docs lint exited 0 beside a non-empty findings list — the shape the sibling engines name a rule bug and fail closed on (repolint.Evaluate, guard.Validate on the committed guard.json, banlist.AddPublic, which refuses to write a severity this loader would happily read). The plain json.Unmarshal compounded it: a misspelt key silently zero-valued the field it missed, so "severty" stripped a rule's exit-code weight and "enabld" disarmed it entirely, both invisible in review. The config is a documented trust boundary in this same file. LoadConfig now decodes strictly (DisallowUnknownFields, the house pattern of baseline.go and its siblings) and refuses an enabled rule or banned token whose severity is outside blocker/warn; a disabled rule stays inert and unchecked. Both live configs load unchanged. Watched-fail tests cover the four refusals and both accepted shapes. Assisted-by: Claude:claude-fable-5
The findings renderer asserted Severity/RuleID are enum-constrained and printed both unsanitised — but neither was: Severity was verbatim committed- config text (now validated at load) and a banned token's RuleID remains free text from the same trust-boundary file, so a hostile clone's config put a raw terminal escape on every finding line. ToUpper does not neutralise OSC/CSI sequences, and RuleID got no transform at all. Sanitise all four fields and correct the comment; a watched-fail test plants an ESC in a token id and asserts it never reaches the terminal. Assisted-by: Claude:claude-fable-5
The shared git primitive returned strings.TrimSpace over the whole capture buffer, and the lifeboat scan NUL-splits that string from ls-files -z to build its not-ignored set. The -z form exists so whitespace in filenames cannot desync the list, but the trim stripped leading whitespace off the first entry, so a repo whose first-sorting path begins with a space had that one file silently classified ignored and dropped from the evidence walk — the quiet-evidence-loss shape the adapter's contract forbids. Trailing NULs survive (NUL is not IsSpace), so only the leading side was damaged. Trim only the trailing side; every RunLimited/RunCapped consumer parses per-line, per-field, or per-NUL and tolerates a leading space, and none may lose one. Watched-fail test pins a leading-space name in first position. Assisted-by: Claude:claude-fable-5
readTrackedFile returned a bare not-ok on an open failure, and the caller warned only on the oversize arm — so a tracked file the scan could not read (EACCES in a shared or container checkout, an I/O fault) was silently skipped and the rule reported the repository clean, against the engine contract that a check that cannot run must not be silently reported as passing. The size branch got exactly this fix (iss-356 item 4); the open branch beside it stayed silent. Surface the open error and warn on the permission/I-O class only: an absent path (deleted in the worktree, sparse checkout) and the symlink-shaped refusals (tracked link leaf, os.Root containment) are legitimate states the scan skips by design, pinned by the existing symlink tests. A polarity table pins the classification; the end-to-end warn test stages a mode-000 tracked file and skips under euid 0, where permission bits do not bind (CI runs unprivileged, so it exercises there). Assisted-by: Claude:claude-fable-5
The RAG row claimed the corpus ships the script-first version, on a page whose sibling rows use ship as a precise delivery marker. Nothing corpus-related exists in the repository or any released artefact — the consult and ingest commands refuse when the corpus is absent — and the script-first-mvp principle names this tooling as its live instance of the rule that a script MVP never ships as product behaviour. State it as the user-tier MVP it is, with core absorption cited (iss-27). Assisted-by: Claude:claude-fable-5
S4 pinned golang:1.25 while go.mod declares go 1.26.7 — the pin matched go.mod when written and the toolchain bump swept every workflow pin but not this file — and an older container refuses a newer go directive with --network=none blocking the GOTOOLCHAIN rescue, so the one command the protocol hands a maintainer failed at the toolchain check and S5's tri-state read the stale pin as an inconclusive contribution. The recipe also carried no module provision, so it failed on dependency fetch regardless of tag. Pin the image to the module toolchain, pre-fetch modules, and mount the module cache read-only before the network is cut. Assisted-by: Claude:claude-fable-5
The frontmatter description enumerated write posture for the bare and build forms only; check — added later, with the argument-hint updated in the same diff but the description left behind — renders the whole site when the output directory has no index.html. The body says so; the summary now does too, matching the house style of naming every form's posture with the surprising one explicit. Assisted-by: Claude:claude-fable-5
Nine records move open/ -> resolved/ with resolved_by provenance naming the fixing commits; the scanIntentTree alignment observation stays open by design. The round is logged in DECISIONS.md. Resolves: iss-2608261532379188 Resolves: iss-2608261532488176 Resolves: iss-2608261533033894 Resolves: iss-2608261533033587 Resolves: iss-2608261533297309 Resolves: iss-2608261533290815 Resolves: iss-2608261533174500 Resolves: iss-2608261533173466 Resolves: iss-2608261533419396 Assisted-by: Claude:claude-fable-5
The banlist editor pins that a top-level annotation key (the JSON commentary convention) still loads, and whole-document DisallowUnknownFields refused it. The misspelt-key hole lives in the rule and banned-token OBJECTS — where the armed enums sit — so each is re-decoded strictly while the top level stays lenient, with the trade-off documented and both polarities pinned. Assisted-by: Claude:claude-fable-5
Merge gate — dual independent adversarial review + CICI on head Review 1 (Claude Opus): SHIP. No blockers. Verified the full diff commit-by-commit; re-ran every watched-fail test against origin/main (all genuinely fail pre-fix, including the privacy end-to-end under an unprivileged uid); enumerated all Review 2 (Claude Fable 5): SHIP. No blockers. Independently rebuilt origin/main and watched all new tests fail pre-fix; ran the full cases suite (17/17), the record/docs/site gates, and race tests on all six touched packages; verified the memory sweep leaves zero raw reads in the package and that Non-blocking follow-up candidates both reviews surfaced (left for a future round rather than widening this diff):
Round 9 (bughunt-a): 21 candidates, 5 substantive + 4 nitpicks confirmed and fixed, 1 recorded open, 11 refuted with grounds — full detail in the PR body and (The final line of this comment is intended to be the trailer below; any "Generated by" footer after it is harness-appended, with no comment-edit tool exposed to this session to strip it.) Assisted-by: Claude:claude-fable-5 Generated by Claude Code |
Assisted-by: Claude:claude-fable-5
What
Bug-hunt round 9 (
bughunt-a). Baseline green before any change (make preflightexit 0 with 77 package results executed, race lane included, after unshallowing this environment's clone;gofmt -l .empty). Five parallel hunters swept the four dimensions; every candidate went to an independent adversarial refuter. 21 candidates in; 5 substantive and 4 nitpicks confirmed and fixed, 1 recorded open, 11 refuted or prior art.Confirmed and fixed — substantive
internal/core/memory/, iss-2608261532379188).ingest.goadjudicates the store a trust boundary and guards its own reads;readOrEmpty(bare status, no type check), both lint WalkDir crawls (WalkDir yields symlinks as non-dir entries),ask's page reads (the exact ReadDir→open TOCTOU ingest documents closing), the quotation-budget config, the coverage index (its literal sibling.sources_index.jsonis guarded), the writer's read-back, and the ingest licence probes all used rawos.ReadFile. Five distinct hangs reproduced via committed mode-120000 fixtures againstabcd memoryandabcd memory lint. All routed throughfsutil.ReadGuarded; watched-fail tests on five paths.scripts/check-issue-resolution.sh, iss-2608261532488176).cd "$(git rev-parse --show-toplevel)"collapses to a successfulcd ""under errexit; thels-treelisting's|| trueread a git failure as an empty ledger; and the shallow probe was disarmed by the same fault it reports. Reproduced with git's dubious-ownership refusal (the reachable form for containers/devcontainers/sudo): 113-records-checked became OK exit 0, with RS003 the sole detector for stamps a squash or rebase merge rewrites. All four probes now rc-check and exit 2 with stderr surfaced (the pattern the siblingcheck-reviews.shratified); the legitimate empty-ledger pass stays loud; three new cases pin both fault shapes and the clean pass, and both fault cases fail against the old gate.internal/core/lint/config.go, iss-2608261533033894).record-lintanddocs lintcountSeverity == "blocker"verbatim and nothing validated the field, so"severity": "blocking"— or a misspelt key zero-valuing it — printed findings beside exit 0; a misspelt"enabled"disarmed a rule outright. The sibling engines (repolint, guard, banlist) fail closed on exactly this vocabulary.LoadConfignow refuses an enabled rule or token with a severity outside blocker/warn and strictly decodes the rule and token objects (unknown keys refused there; the top level stays lenient for annotation keys, pinned by the banlist editor). Both live configs load unchanged; watched-fail tests cover the refusals and accepted shapes.docs/reference/terminology.mdclaimed the sources corpus "ships" (iss-2608261533174500). Nothing corpus-related exists in the repository or any released artefact; the consult/ingest commands refuse when it is absent; and the committedscript-first-mvpprinciple names this tooling as its live instance of "a script MVP never ships as product behaviour". The row now states the user-tier script MVP with core absorption cited (iss-27)..abcd/work/intake.md, iss-2608261533173466). S4 pinnedgolang:1.25against ago 1.26.7module with--network=noneblocking theGOTOOLCHAINrescue — drift from the toolchain bump that swept every workflow pin but not this file — and the recipe carried no module provision, so it failed on dependency fetch regardless of tag. The recipe now pins the module toolchain, pre-fetches modules, and mounts the module cache read-only.Confirmed and fixed — nitpick
runBoundedtrimmed leading whitespace off the first NUL-list entry (internal/gitutil/repo.go, iss-2608261533297309): a leading-whitespace filename in firstls-files -zposition was silently classified ignored and dropped from the lifeboat scan. Trailing-side trim only; leading-space fixture pinned.internal/core/repolint/rule_privacy.go, iss-2608261533290815): the oversize arm warned (iss-356 item 4) while the open arm beside it stayed silent. EACCES/EPERM/EIO now warn not-scanned; absent paths and symlink-shaped refusals stay silent by design (pinned polarity table; the end-to-end mode-000 test skips under euid 0 and exercises on CI's unprivileged runners).internal/surface/cli/cli.go, iss-2608261533033587): a token id from the committed config put a raw ESC on every finding line. All four fields sanitised, comment corrected, watched-fail ESC test.commands/site.md's description omitted thatcheckrenders the site when the output directory has noindex.html(iss-2608261533419396): the stale two-form roster now discloses all three write postures, matching the body and house style.Recorded open (not fixed)
scanIntentTreeand the two spec-store stat probes swallow every stat error as tree-absent, unlike the sibling scanners that part ENOENT from real faults. The refuter proved every claimed vacuous-blocker trigger closed upstream (markdownFiles,os.ReadDir, the armeddelivery_statefloor), so this is a consistency alignment for a scoped consolidation, not a live defect.Considered and rejected (refuted, with grounds)
planned//drafts/spc-28 rename sweep — deliberately unswept under the open iss-94 convention ("each reconciles when next planned"), which the in-flight round-9-b branch writes into spc-28 itself; most occurrences are inert template stubs with a pinned tolerance in the audit code.capture promoterow — real drift, but inside the maintainer-embargoed brief-drift evidence corpus (iss-2608231346137587, "do not fix the 124 by hand"); round 8 refuted a sibling on the same ground.isHexSHA40-hex (a prior round's refutation stands verbatim in DECISIONS: display-only, sha256 repos unreachable, iss-206 owns the sibling);frontmatterOpen's comment latch (the latch returns the same verdict a correct parser would); thereadSkewMeta/readPinnedTagprecedence claim (the truncated-file premise is closed by the bootstrap's atomic rename, and silence is the pinned policy);guard checkexit 2 on a disabled registry (specified in the brief, message names the state); theprepare-this-repofour-bucket gloss (the arrow chain is the standalone-lifecycle shorthand; disciplines are a separate track by design); the MADR label (the adopted meaning is the recorded §4 consensus and matches practice); the itd-27 "his" candidate (a cited real third party is outside the persona rule's subjects, confirmed by two prior sweeps); therecord-lintjob-name mismatch (the ruleset file mirrors live GitHub state by construction; the misdescribing prose is already fixed in flight).Verification
make preflightclean (exit 0), full suite executed including the race lane;gofmt -l .empty.resolved_byprovenance naming each fixing commit; the round is logged in.abcd/work/DECISIONS.md.Round: 9 (bughunt-a). Model routing: orchestration on Claude Opus 5; hunters and refuters as independent Claude Opus 4.8 subagents; the dual PR review to follow runs one Claude Opus 5 and one Claude Fable 5 reviewer.
Assisted-by: Claude:claude-fable-5