Skip to content

abcd memory (bare status) and abcd memory lint crawl store pages with raw os.ReadFile — no regular-file check, no O_NOFOLLOW, no size cap — so a committed .abcd/memory/*.md symlink is followed: a FIFO//dev/zero target hangs or OOMs the CLI and an out-of-store target is read into the quotation-coverage arithmetic (unswept sibling of #336) #363

Description

@REPPL

Wrong assumption

That anything under .abcd/memory/ ending in .md is a readable, bounded, regular file. Every other page reader in the package guards this — pageFiles (writer.go:325), QueryPages (ask.go:214), barePageInfos (bare.go:111), existingPageFrontmatter (ingest.go:452) all require e.Type().IsRegular(), and pageHashSet/existingPageFrontmatter read through fsutil.ReadGuarded(path, maxMemoryPageBytes) (O_NOFOLLOW + regular-file-on-fd + size cap). The maxMemoryPageBytes doc comment (ingest.go:420-424) names the 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." The lint and bare-status crawls skip that gate.

Location (all reached from the CLI)

  • internal/core/memory/lint.go:91isTypedMemoryPagePath does a raw os.ReadFile(path); this is the first, unavoidable read, and it precedes parseFrontmatter.
  • internal/core/memory/lint.go:293-307 — coverage-lint WalkDir crawl; filter is only err/d.IsDir()/.md-suffix/isTypedMemoryPagePath, then raw os.ReadFile (a symlink dirent is not a dir, so it passes).
  • internal/core/memory/lint.go:415-421Lint's per-page raw read.
  • internal/core/memory/bare.go:181-194 — the headroom crawl behind abcd memory (bare), the default read-only status verb.

Trigger (observed vs correct)

A branch commits .abcd/memory/fact_testing_hostile.md as a symlink (git stores symlinks as mode 120000; .abcd/memory/ is not gitignored, so it travels with a clone). A relative target survives a fresh git clone. Empirically, with the prebuilt binary:

  • target /dev/zero: abcd memory lint never returns — RSS climbs ~150 MB/s unbounded (fd open on /dev/zero); abcd memory bare hangs identically once a .coverage_index.json exists (any store that has been linted once). Control store without the symlink completes in milliseconds. abcd memory ask on the same store returns normally — its IsRegular() gate skips the symlink dirent, so the divergence between readers is directly observable.
  • target a FIFO: both verbs block forever.
  • target a regular file outside the store: abcd memory lint emits an MS001 finding against the out-of-store content and folds it into the rebuilt .coverage_index.json, while abcd memory ask denies the page exists — lint computes quotation coverage over content the store's own verbs do not admit.

Correct behaviour: the same IsRegular() + ReadGuarded gate the rest of the package applies.

Why it fails silently

The absorbed out-of-store content never appears as a page in abcd memory's count, index.md, or ask results, so the resulting MQ002/MQ003 numbers cite a corpus the operator cannot reconstruct — and because the coverage fingerprint covers the crawled set, bare's headroom line agrees with lint, giving a consistent-looking but wrong answer. (The hang/OOM is loud; the content-absorption is the silent half.)

Sibling sweep / scope

Same delivery vector, directory, and class as issue #336 (a committed .abcd/memory symlink followed by a raw os.ReadFile), which was rated critical; #336 fixed triStateRead's four store siblings, and this is the unswept WalkDir-crawl sibling — arguably broader, since any name matching <type>_<domain>_<slug>.md works and the affected verbs include the default status verb. This matches the playbook's own 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).

Defences checked and refuted: the frontmatter/source: gate cannot bound it (the raw read at lint.go:91 precedes parseFrontmatter); ReadGuarded failure would land in the existing silent-skip branch (lint.go:419/:92 already continue on read error, and there is no unreadable/oversize finding code), so hardening removes no capability and only makes lint agree with ingest; and the trusted-worktree exemption is explicitly opted out of for memory pages by the maxMemoryPageBytes comment, which scopes itself to the object, not the verb.

CWE

CWE-59 (link following) and CWE-400 (uncontrolled resource consumption); CWE-367 (ReadDir→open TOCTOU) on the crawl.

Fix direction (one line)

Route all four crawl reads through fsutil.ReadGuarded(path, maxMemoryPageBytes) and gate the WalkDir dirents on IsRegular(), matching every other page reader in the package.

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