Skip to content

Bug-hunt round 9: guarded memory reads, a fail-closed resolution gate, and validated lint severities - #521

Merged
REPPL merged 12 commits into
mainfrom
bughunt-a/round-9
Aug 26, 2026
Merged

Bug-hunt round 9: guarded memory reads, a fail-closed resolution gate, and validated lint severities#521
REPPL merged 12 commits into
mainfrom
bughunt-a/round-9

Conversation

@REPPL

@REPPL REPPL commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

What

Bug-hunt round 9 (bughunt-a). Baseline green before any change (make preflight exit 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

  1. Every memory-store reader outside ingest followed a committed symlink unbounded (internal/core/memory/, iss-2608261532379188). ingest.go adjudicates 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.json is guarded), the writer's read-back, and the ingest licence probes all used raw os.ReadFile. Five distinct hangs reproduced via committed mode-120000 fixtures against abcd memory and abcd memory lint. All routed through fsutil.ReadGuarded; watched-fail tests on five paths.
  2. The issue-resolution gate reported OK having scanned zero records whenever a git probe failed (scripts/check-issue-resolution.sh, iss-2608261532488176). cd "$(git rev-parse --show-toplevel)" collapses to a successful cd "" under errexit; the ls-tree listing's || true read 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 sibling check-reviews.sh ratified); 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.
  3. A lint rule with an off-enum severity emitted findings that counted toward no exit code (internal/core/lint/config.go, iss-2608261533033894). record-lint and docs lint count Severity == "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. LoadConfig now 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.
  4. docs/reference/terminology.md claimed 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 committed script-first-mvp principle 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).
  5. The intake protocol's contained-review container cannot build the module (.abcd/work/intake.md, iss-2608261533173466). S4 pinned golang:1.25 against a go 1.26.7 module with --network=none blocking the GOTOOLCHAIN rescue — 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

  • runBounded trimmed leading whitespace off the first NUL-list entry (internal/gitutil/repo.go, iss-2608261533297309): a leading-whitespace filename in first ls-files -z position was silently classified ignored and dropped from the lifeboat scan. Trailing-side trim only; leading-space fixture pinned.
  • privacy-hygiene silently skipped a tracked file it could not open (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).
  • The docs-lint renderer printed config-derived Severity/RuleID unsanitised behind a comment claiming they are enum-constrained (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 that check renders the site when the output directory has no index.html (iss-2608261533419396): the stale two-form roster now discloses all three write postures, matching the body and house style.

Recorded open (not fixed)

  • iss-2608261533419897scanIntentTree and 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 armed delivery_state floor), so this is a consistency alignment for a scoped consolidation, not a live defect.

Considered and rejected (refuted, with grounds)

  • The 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.
  • The verification-matrix capture promote row — 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.
  • The attribution gate's fence concession being dead under the PR template — documented deliberate trade-off (iss-268 resolution, iss-270 wontfix precedent), corpus-pinned; a differential run over all 453 historical PR bodies showed zero verdict flips.
  • isHexSHA 40-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); the readSkewMeta/readPinnedTag precedence claim (the truncated-file premise is closed by the bootstrap's atomic rename, and silence is the pinned policy); guard check exit 2 on a disabled registry (specified in the brief, message names the state); the prepare-this-repo four-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); the record-lint job-name mismatch (the ruleset file mirrors live GitHub state by construction; the misdescribing prose is already fixed in flight).

Verification

  • make preflight clean (exit 0), full suite executed including the race lane; gofmt -l . empty.
  • Every behaviour change carries a test watched failing before the change and passing after (the one exception is documented in its commit: the privacy-hygiene end-to-end stages EACCES, which cannot bind under euid 0, so its fail-before is carried by the pinned polarity table and CI's unprivileged runners).
  • Ledger records were captured before fixing and resolved with resolved_by provenance 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

REPPL added 11 commits August 26, 2026 15:36
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
@REPPL
REPPL enabled auto-merge August 26, 2026 16:17
@REPPL
REPPL added this pull request to the merge queue Aug 26, 2026

REPPL commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merge gate — dual independent adversarial review + CI

CI on head c599c749: all pull-request checks green — ci ✅, attribution ✅, external-review ✅, site-screenshots ✅. (The first attribution run failed on the stale opened-event payload, which still carried a harness-appended body footer; the body was already edited clean, and the edited-event run — the trigger that exists for exactly this case — passed on the same head.) The maintainer enabled auto-merge (merge commit) and the PR has entered the merge queue; this session performs no merge action.

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 RunLimited/RunCapped consumers for the trim change (none depends on leading trim); confirmed both live configs load under the scoped strict decoding; verified RS001/RS002/RS003 across the branch and make preflight exit 0 on a scratch copy.

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 ArmReceiptGate/ArmCitationOverdue are unaffected by the loader validation.

Non-blocking follow-up candidates both reviews surfaced (left for a future round rather than widening this diff):

  • overdue_severity shares the unvalidated-severity shape the config fix closed for severity (citations overdue findings could still carry an off-enum value) — the one member of the class left open.
  • banlist.writePublic validates with a plain unmarshal, so write-time "parses" is now weaker than load-time "loads".
  • capRead still swallows a mid-read I/O error silently — the same not-scanned shape one arm up.
  • Commit ac06c817 is red in isolation (repaired by the tip commit c599c749); a rebase-merge bisect would step through it. The resolution stamps also cite branch shas, so a merge commit — the method auto-merge is set to — is the right landing; a squash or rebase merge would trip RS003 on main by design.
  • The intake S4 recipe is corrected but still never executed by any gate, so the pin can drift again; and the GOMODCACHE read-only mount may still need a writable cache/lock in some Go versions.

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 .abcd/work/DECISIONS.md.

(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

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 26, 2026
Assisted-by: Claude:claude-fable-5
@REPPL
REPPL enabled auto-merge August 26, 2026 16:52
@REPPL
REPPL added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 4707f68 Aug 26, 2026
12 checks passed
@REPPL
REPPL deleted the bughunt-a/round-9 branch August 26, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant