Skip to content

abcd memory lint reads .abcd/memory/config.json (quotation budget) with raw os.ReadFile — no O_NOFOLLOW, no regular-file check, no size cap — so a committed symlinked config.json is followed: a FIFO//dev/zero target hangs or OOMs the CLI (unswept metadata sibling of #336/#363) #365

Description

@REPPL

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) unconditionallyrunMemoryCoverageLint 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-fixvalidated bug awaiting a fixseverity:majorwrong result on reachable input

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions