Wrong assumption
That .abcd/memory/config.json and .abcd/memory/.coverage_index.json are readable, bounded, regular files. The memory package's own convention says otherwise: the maxMemoryPageBytes doc comment (internal/core/memory/ingest.go:420-425) names this exact threat — "an in-store page name can itself be a committed symlink to /dev/zero, and os.ReadFile would follow it and grow without bound" — and every page reader (pageHashSet, existingPageFrontmatter) routes through fsutil.ReadGuarded (O_NOFOLLOW + regular-file-on-fd + size cap). The store's metadata/config readers were left on raw os.ReadFile. These are fixed names built by string join (Dir(repoRoot)/config.json), not entries from a ReadDir crawl, so there is no upstream IsRegular() dirent filter in play at all — the read is the first and only gate.
Location (all reached from the CLI, repo-relative)
Trigger (observed vs correct)
A branch commits .abcd/memory/config.json as a symlink (git stores it as mode 120000; .abcd/memory/ is not gitignored — .gitignore covers only coverage.html/.txt — so it travels with a clone). A relative target survives a fresh git clone (verified: config.json -> ../../../evil-fifo clones back as a 120000 link).
Empirically, in a scratch repo with zero prior setup (no pages, no index — just the committed symlink), built from the current tree:
config.json → a FIFO: abcd memory lint never returns (exit 124 under a timeout). os.ReadFile opens without O_NONBLOCK, so the open blocks on the FIFO forever.
config.json → /dev/zero: the read grows unbounded (OOM), since io.ReadAll is fed endless bytes with no cap.
.coverage_index.json → a FIFO: abcd memory (bare status) hangs identically — this read (bare.go:164) runs before anything else in the headroom path.
Correct behaviour: the same fsutil.ReadGuarded gate the rest of the package applies (O_NOFOLLOW + regular-file-on-fd + size cap), landing an unreadable file in the existing error branch (return def / "not built yet") — no capability lost.
Reachability (precise)
abcd memory lint: reaches loadQuotationBudget(config.json) unconditionally — runMemoryCoverageLint gates only on os.Stat(mem).IsDir() (lint.go:288) and then calls loadQuotationBudget at lint.go:238 and lint.go:313. This is the primary trigger; it needs no pages and no pre-existing index.
abcd memory (bare status, the default verb): reaches the .coverage_index.json read (bare.go:164) and readOrEmpty(contradictions.md) (bare.go:70) unconditionally, so a symlink at those names hangs it on a fresh clone; it reaches loadQuotationBudget(config.json) only once a real .coverage_index.json exists (i.e. after a prior lint built it), because bareHeadroomLines returns early when the index is absent.
Why it fails silently (the non-loud half)
The loud half is the hang/OOM. The quieter half: a symlinked config.json pointing at a regular file outside the store is followed and its bytes are parsed as the quotation budget, so abcd memory lint's MQ001/MQ002 numbers are computed against a budget the operator cannot see in the store — an out-of-store input steering a gate's arithmetic, the same silent-absorption shape #363 flagged for pages.
Sibling sweep / scope
Same delivery vector, directory, and class as #336 (critical) and #363 (major): a committed .abcd/memory/* symlink followed by a raw os.ReadFile. #336 hardened triStateRead (the writer-path store siblings in writer.go); #363 hardened only the typed-.md-page crawl (lint.go:91, lint.go:293-307, lint.go:415-421, bare.go:181-194), which excludes sibling files via isTypedMemoryPagePath. Neither issue enumerates config.json or .coverage_index.json — a fix for either would not touch these reads. Sweep of the memory package's remaining raw metadata/config reads, each checked: coverage.go:63 (config.json — confirmed), coverage.go:523 (.coverage_index.json — confirmed), bare.go:164 (.coverage_index.json — confirmed), bare.go:260 (readOrEmpty of contradictions.md/index.md — status-path reader of files #336 hardened only on the writer path). This matches the playbook's top heuristic ("a pattern was just fixed in one spot — did it land at every sibling site?") and its highest-leverage recommendation (a sweep keyed off fsutil.ReadGuarded).
CWE
CWE-59 (link following) and CWE-400 (uncontrolled resource consumption / unbounded read + blocking open).
Fix direction (one line)
Route the four metadata/config reads through fsutil.ReadGuarded(path, cap), matching every other reader in the package; an unreadable/non-regular/oversize file lands in the existing default/"not built" branch.
Validator confirmations
Wrong assumption
That
.abcd/memory/config.jsonand.abcd/memory/.coverage_index.jsonare readable, bounded, regular files. The memory package's own convention says otherwise: themaxMemoryPageBytesdoc comment (internal/core/memory/ingest.go:420-425) names this exact threat — "an in-store page name can itself be a committed symlink to/dev/zero, andos.ReadFilewould follow it and grow without bound" — and every page reader (pageHashSet,existingPageFrontmatter) routes throughfsutil.ReadGuarded(O_NOFOLLOW + regular-file-on-fd + size cap). The store's metadata/config readers were left on rawos.ReadFile. These are fixed names built by string join (Dir(repoRoot)/config.json), not entries from aReadDircrawl, so there is no upstreamIsRegular()dirent filter in play at all — the read is the first and only gate.Location (all reached from the CLI, repo-relative)
internal/core/memory/coverage.go:63—loadQuotationBudgetdoes a rawos.ReadFile(memoryConfigPath(repoRoot))=.abcd/memory/config.json. Its only handling isif err != nil { return def }; the read never completes on a blocking/endless target, so that branch never fires.internal/core/memory/coverage.go:523—readStoredFingerprintraw-reads.abcd/memory/.coverage_index.json.internal/core/memory/bare.go:164—bareHeadroomLinesraw-reads.coverage_index.json.internal/core/memory/bare.go:260—readOrEmpty, raw-readscontradictions.md/index.mdon the status path (a distinct reader fromtriStateReadreads the four memory-store siblings with a rawos.ReadFile, so a committed.abcd/memory/log.mdsymlink is FOLLOWED andappendLogthen rewrites the target's bytes into the store as a tracked regular file — and a FIFO/device leaf blocks the same read forever with the exclusive store flock held #336's writer-pathtriStateRead).Trigger (observed vs correct)
A branch commits
.abcd/memory/config.jsonas a symlink (git stores it as mode120000;.abcd/memory/is not gitignored —.gitignorecovers onlycoverage.html/.txt— so it travels with a clone). A relative target survives a freshgit clone(verified:config.json -> ../../../evil-fifoclones back as a120000link).Empirically, in a scratch repo with zero prior setup (no pages, no index — just the committed symlink), built from the current tree:
config.json→ a FIFO:abcd memory lintnever returns (exit 124under atimeout).os.ReadFileopens withoutO_NONBLOCK, so the open blocks on the FIFO forever.config.json→/dev/zero: the read grows unbounded (OOM), sinceio.ReadAllis fed endless bytes with no cap..coverage_index.json→ a FIFO:abcd memory(bare status) hangs identically — this read (bare.go:164) runs before anything else in the headroom path.Correct behaviour: the same
fsutil.ReadGuardedgate the rest of the package applies (O_NOFOLLOW + regular-file-on-fd + size cap), landing an unreadable file in the existing error branch (return def/ "not built yet") — no capability lost.Reachability (precise)
abcd memory lint: reachesloadQuotationBudget(config.json)unconditionally —runMemoryCoverageLintgates only onos.Stat(mem).IsDir()(lint.go:288) and then callsloadQuotationBudgetatlint.go:238andlint.go:313. This is the primary trigger; it needs no pages and no pre-existing index.abcd memory(bare status, the default verb): reaches the.coverage_index.jsonread (bare.go:164) andreadOrEmpty(contradictions.md)(bare.go:70) unconditionally, so a symlink at those names hangs it on a fresh clone; it reachesloadQuotationBudget(config.json)only once a real.coverage_index.jsonexists (i.e. after a priorlintbuilt it), becausebareHeadroomLinesreturns early when the index is absent.Why it fails silently (the non-loud half)
The loud half is the hang/OOM. The quieter half: a symlinked
config.jsonpointing at a regular file outside the store is followed and its bytes are parsed as the quotation budget, soabcd memory lint's MQ001/MQ002 numbers are computed against a budget the operator cannot see in the store — an out-of-store input steering a gate's arithmetic, the same silent-absorption shape #363 flagged for pages.Sibling sweep / scope
Same delivery vector, directory, and class as #336 (critical) and #363 (major): a committed
.abcd/memory/*symlink followed by a rawos.ReadFile. #336 hardenedtriStateRead(the writer-path store siblings inwriter.go); #363 hardened only the typed-.md-page crawl (lint.go:91,lint.go:293-307,lint.go:415-421,bare.go:181-194), which excludes sibling files viaisTypedMemoryPagePath. Neither issue enumeratesconfig.jsonor.coverage_index.json— a fix for either would not touch these reads. Sweep of the memory package's remaining raw metadata/config reads, each checked:coverage.go:63(config.json — confirmed),coverage.go:523(.coverage_index.json — confirmed),bare.go:164(.coverage_index.json — confirmed),bare.go:260(readOrEmpty of contradictions.md/index.md — status-path reader of files #336 hardened only on the writer path). This matches the playbook's top heuristic ("a pattern was just fixed in one spot — did it land at every sibling site?") and its highest-leverage recommendation (a sweep keyed offfsutil.ReadGuarded).CWE
CWE-59 (link following) and CWE-400 (uncontrolled resource consumption / unbounded read + blocking open).
Fix direction (one line)
Route the four metadata/config reads through
fsutil.ReadGuarded(path, cap), matching every other reader in the package; an unreadable/non-regular/oversize file lands in the existing default/"not built" branch.Validator confirmations
runMemoryCoverageLintcallsloadQuotationBudgetunconditionally atlint.go:313(only gate:os.Stat(mem).IsDir()); a committed relative-target symlink stages as120000and survivesgit clone; scratch-repo repro with no prior setup —config.json -> FIFO,abcd memory lint→ exit 124 (hung).config.jsonis a genuine unswept sibling (triStateReadreads the four memory-store siblings with a rawos.ReadFile, so a committed.abcd/memory/log.mdsymlink is FOLLOWED andappendLogthen rewrites the target's bytes into the store as a tracked regular file — and a FIFO/device leaf blocks the same read forever with the exclusive store flock held #336 hardenedtriStateRead;abcd memory(bare status) andabcd memory lintcrawl store pages with rawos.ReadFile— no regular-file check, noO_NOFOLLOW, no size cap — so a committed.abcd/memory/*.mdsymlink is followed: a FIFO//dev/zerotarget hangs or OOMs the CLI and an out-of-store target is read into the quotation-coverage arithmetic (unswept sibling of #336) #363 the.mdcrawl, which excludes config). The secondary "bare default verb reaches config.json" claim is gated behind a pre-existing coverage index, but thelintpath is unconditional.loadQuotationBudget(coverage.go:61-66) is a bareos.ReadFilewith no IsRegular/O_NOFOLLOW/size cap; path is a fixed name so no dirent filter applies; materially identical to the class the package's ownmaxMemoryPageBytescomment andfsutil.ReadGuardedusers harden; FIFO blocks on open,/dev/zerogrows unbounded, and thereturn deferror branch never fires because the read never completes. Could not refute it.