From 3cfff31b0e5847baec4a1e7a5b1873669c36b608 Mon Sep 17 00:00:00 2001 From: u00dxk2 Date: Sat, 29 Aug 2026 12:27:04 -0600 Subject: [PATCH 1/3] fix(memory-integrity): the budget constant was 585 bytes too generous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT_INDEX_BUDGET_BYTES read `Math.floor(24.4 * 1024)` = 24,985 — the loader's documented "24.4KB" re-typed as KiB where the number is decimal. The one check that can catch a truncating memory index carried 585 bytes of slack it was never granted, in the only direction that matters: it let a truncated index pass. Pinned to the literal 24,400. The existing budget test asserted against `DEFAULT_INDEX_BUDGET_BYTES + 1`, so it passed just as happily on the wrong constant. The new one pins the literal and the boundary: clean at 24,400, WARN at 24,401. Catches the file up with its origin on two more legs: - phantom-tool (opt-in): an index line NAMING a tool or script that resolves to no file. The live instance was an index naming an "open-items projection" tool that never existed — a compression pass kept the name and dropped the recipe, and the line stayed unfollowable for eleven days. Extraction is pure; existence is judged by a caller-supplied `toolResolver` so the file stays fs-free. Absent resolver, the check does not run; `null` or a throw means can't-judge, which yields no finding. - classifyMemoryIndexSizes: the fleet read, whose failure the single-dir lint doesn't have — an agent whose index you could not measure. MISSING is fail-closed and a finding; a dir that never existed is declared, not swept; `sweptCount` counts only what was measured, so a run that measured nothing returns "nothing-swept" rather than "clean", and findings dominate. Deliberately NOT ported: mungeProjectPath and stripWorktreeSuffix. Both encode Claude-Code-specific path shapes (`~/.claude/projects` name munging, `.claude/worktrees` suffixes) and both are about LOCATING files, which this library does not do. 188 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CkTF1JryMkGt4Qx2A2vHEh --- README.md | 2 +- lib/memory-integrity.mjs | 218 ++++++++++++++++++++++++++++++++- test/memory-integrity.test.mjs | 80 ++++++++++++ 3 files changed, 294 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index af92441..1c2a88a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ both fixes are in the history. | Artifact | What it does | How to adopt | |---|---|---| | [`lib/snippet-redact.mjs`](./lib/snippet-redact.mjs) | Redacts secret-shaped text (API keys, JWTs, DB URIs, PEM blocks…) at the output boundary of any recall/search path, with named shape tokens (`[redacted:github-token]`) so hits stay findable. Defense-in-depth, not DLP — see [Coverage and limits](#coverage-and-limits). | **Use as-is** — vendor the one file (zero deps, pure function) | -| [`lib/memory-integrity.mjs`](./lib/memory-integrity.mjs) | Zero-LLM integrity pass over a markdown agent-memory dir (MEMORY.md-style index + per-fact files + `[[wiki-links]]` — the Claude Code auto-memory shape): dead links, silent merges, over-budget index, orphans, near-duplicates; plus a backlink graph and **suggest-only** repairs. | **Use as-is** — vendor the one file; wire a thin CLI to your memory dir | +| [`lib/memory-integrity.mjs`](./lib/memory-integrity.mjs) | Zero-LLM integrity pass over a markdown agent-memory dir (MEMORY.md-style index + per-fact files + `[[wiki-links]]` — the Claude Code auto-memory shape): dead links, silent merges, over-budget index, orphans, near-duplicates, and index lines naming a tool that doesn't exist; plus a backlink graph, **suggest-only** repairs, and a fleet-wide index-size classifier where an agent you couldn't measure is a finding rather than a clean row. | **Use as-is** — vendor the one file; wire a thin CLI to your memory dir | | [`lib/secret_redaction.py`](./lib/secret_redaction.py) | The same output-boundary redaction for Python recall paths (agent-memory layers, log excerpting) — a faithful port of `snippet-redact.mjs`, kept in sync; extracted while proposing this boundary upstream to a Python memory framework ([mem0ai/mem0#6817](https://github.com/mem0ai/mem0/issues/6817)). | **Use as-is** — vendor the one file (stdlib-only); `python lib/secret_redaction.py` runs its self-check | | [`lib/capability-grant.mjs`](./lib/capability-grant.mjs) | Scoped, single-use, TTL-bounded capability grants for human-gated agent actions: an approval relayed through a chat/bus message is not authorization, so a direct human "go" mints a grant bound to the sha256 of one exact command, honored once. Fail-closed: any ambiguity falls through to your normal permission prompt. | **Reference logic** — pure and complete as logic, and **not an authorization boundary on its own**. You supply: complete, non-bypassable mediation (no execution path reaches the action except through the hook); an authenticated *and* authorized minting principal, minting outside any agent session; a grant store, class policy and audit log the agent cannot write or delete; random ids; scope and class resolution; a trusted clock; atomic consume-before-execute; executing the same captured value that was checked; binding or independently trusting mutable context (cwd, PATH, executable resolution, environment, shell, referenced files) — a command hash does not bind a command's *effect* when those can change; and a fallback to your normal prompt. Budget real work here, not a wrapper | | [`lib/stale-basis.mjs`](./lib/stale-basis.mjs) | One staleness chain for tracker/memory items — newest of the declared *signal* dates (fields stamped only when an item was actually looked at), with bulk-write `updated` timestamps deliberately excluded so a mass edit can't silently re-date the whole tracker. Verdicts name which basis won. | **Use as-is** — vendor the one file; import it from EVERY reader (two hand-rolled copies of a staleness chain will drift) | diff --git a/lib/memory-integrity.mjs b/lib/memory-integrity.mjs index db7359b..5433115 100644 --- a/lib/memory-integrity.mjs +++ b/lib/memory-integrity.mjs @@ -23,6 +23,8 @@ * failure: distinct facts sharing one identity) * index-over-budget WARN the index exceeds the session load budget, so * part of it silently doesn't load + * phantom-tool WARN an index line names a tool/script that resolves + * to no file (opt-in: needs a `toolResolver`) * dangling-wiki-link INFO a [[wiki-link]] resolves to no file — allowed * by convention (it marks something worth writing * later), surfaced so it eventually gets written @@ -42,8 +44,28 @@ * human applies them. Nothing here edits anything. */ -/** Default index load budget. Claude Code's session loader warns at ~24.4KB. */ -export const DEFAULT_INDEX_BUDGET_BYTES = Math.floor(24.4 * 1024); +/** + * Default index load budget: **24,400 LF-normalized UTF-8 bytes**. + * + * ⚠ CORRECTED 2026-08-29. This read `Math.floor(24.4 * 1024)` = **24,985** — + * the loader's documented "24.4KB" re-typed as KiB where the number is + * decimal. The one check that can catch a truncating index carried 585 bytes + * of slack it was never granted, in the only direction that matters: it let a + * truncated index pass. Pinned to the literal, with a test at 24,401, so the + * re-typing cannot come back. + * + * Measure the index in LF-normalized UTF-8 bytes. A raw `statSync().size` on a + * Windows working tree reads +1 per line and flips an index over the cap for + * its line endings alone. + */ +export const DEFAULT_INDEX_BUDGET_BYTES = 24_400; + +/** + * The sentence a budget finding should carry, so every surface (CLI, report, + * dashboard) says the same thing about WHY the byte count matters: nothing + * errors, nothing warns, the tail is simply gone. + */ +export const LOADER_TRUNCATION_SENTENCE = "the loader drops the tail silently past this line"; /** Below this normalized-title Jaccard, two entries sharing a target are "clearly different". */ const DUPLICATE_TARGET_MAX_JACCARD = 0.2; @@ -153,6 +175,45 @@ export function extractWikiLinks(content) { return out; } +// ---- phantom-tool leg ---- +// An index line NAMING a tool or script must resolve to an existing file. The +// live instance: an index named an "open-items projection" tool that never +// existed — a compression pass had kept the NAME and dropped the inline +// recipe, leaving the line unfollowable for eleven days. Every reader saw a +// capability the fleet did not have. +// +// Extraction is pure; EXISTENCE is judged by a caller-supplied resolver, so +// this file stays fs-free and the check stays testable without a disk. + +// A tool-shaped token: optional relative path prefix + basename with a script +// extension. Placeholders (), URLs, and glob-ish tokens are excluded. +const TOOL_REF_RE = /(?])((?:\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:mjs|cjs|ts|sh|py))(?![\w/])/g; + +/** + * Extract tool/script references from index text. Pure. Dedupes by token, + * keeping the first line each appears on. Lines carrying a URL are skipped + * whole (too false-positive-prone), as are `` tokens. + * @param {string} text + * @returns {Array<{token: string, line: number}>} + */ +export function extractToolReferences(text) { + if (typeof text !== "string" || text.length === 0) return []; + const seen = new Map(); + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/https?:\/\//.test(line)) continue; // URL-bearing line: skip entirely + let m; + TOOL_REF_RE.lastIndex = 0; + while ((m = TOOL_REF_RE.exec(line)) !== null) { + const token = m[1]; + if (token.includes("<") || token.includes(">")) continue; + if (!seen.has(token)) seen.set(token, i + 1); + } + } + return [...seen.entries()].map(([token, line]) => ({ token, line })); +} + /** * The full integrity pass. Pure — caller reads the dir and hands in content. * @@ -160,7 +221,12 @@ export function extractWikiLinks(content) { * @param {string} input.indexText index file content (conventionally MEMORY.md) * @param {Array<{name: string, content?: string}>} input.files topic files (the index is skipped by name) * @param {number} [input.indexByteLength] byte size of the index on disk - * @param {number} [input.budgetBytes] load budget (default ~24.4KB) + * @param {number} [input.budgetBytes] load budget (default 24,400 bytes) + * @param {(token: string) => boolean|null|undefined} [input.toolResolver] + * OPT-IN existence check for tool/script names in the index. `true` = exists, + * `false` = provably absent (the only value that yields a finding), + * `null`/`undefined`/throw = can't judge → NO finding. Omit it and the + * phantom-tool check does not run at all. * @returns {{findings: Array<{type: string, severity: "warn"|"info", message: string, detail?: object}>, swept: boolean, coverage: {reached: number, skipped: number, malformed: number, contentless: number, indexExcluded: number, indexRead: boolean}}} * `reached` = non-index entries with a string name AND string content (empty * string counts — it was supplied). `malformed` = null, or a missing/non-string @@ -183,7 +249,7 @@ export function extractWikiLinks(content) { * independently, at the call site that did the I/O. Subjects omitted before * invocation are invisible here by construction. */ -export function lintMemoryIntegrity({ indexText, files, indexByteLength, budgetBytes } = {}) { +export function lintMemoryIntegrity({ indexText, files, indexByteLength, budgetBytes, toolResolver } = {}) { const findings = []; const rawList = Array.isArray(files) ? files : []; // Three outcomes, counted separately, because they demand different reactions. @@ -250,13 +316,37 @@ export function lintMemoryIntegrity({ indexText, files, indexByteLength, budgetB } } + // --- phantom tools --- + // Only when the caller supplies a resolver; this file never touches a disk. + // Resolver contract: true = exists, false = provably absent, anything else + // (including a throw) = can't judge → NO finding. Fail-soft, as everywhere + // here: the lint may under-report, never over-report. + if (typeof toolResolver === "function") { + for (const ref of extractToolReferences(indexText)) { + let exists = null; + try { + exists = toolResolver(ref.token); + } catch { + exists = null; + } + if (exists === false) { + findings.push({ + type: "phantom-tool", + severity: "warn", + message: `index:${ref.line} names \`${ref.token}\`, which resolves to no file — a compression pass can keep a tool's name and drop its recipe, leaving the line unfollowable`, + detail: { token: ref.token, line: ref.line }, + }); + } + } + } + // --- index size --- const budget = Number.isFinite(budgetBytes) && budgetBytes > 0 ? budgetBytes : DEFAULT_INDEX_BUDGET_BYTES; if (Number.isFinite(indexByteLength) && indexByteLength > budget) { findings.push({ type: "index-over-budget", severity: "warn", - message: `index is ${indexByteLength} bytes (budget ${budget}) — the tail won't load; trim digest lines into topic files`, + message: `index is ${indexByteLength} bytes (budget ${budget}) — ${LOADER_TRUNCATION_SENTENCE}; trim digest lines into topic files`, detail: { indexByteLength, budgetBytes: budget }, }); } @@ -459,6 +549,124 @@ export function buildMemoryLinkGraph({ files } = {}) { }; } +/** Per-agent verdicts from `classifyMemoryIndexSizes`. */ +export const MEMORY_SIZE_STATES = Object.freeze({ + OVER: "over", + OK: "ok", + MISSING: "missing", + NO_MEMORY_DIR: "no-memory-dir", +}); + +/** + * Classify ONE read of every agent's index size across a fleet. + * + * `lintMemoryIntegrity` answers "is this one memory dir healthy?". This answers + * the fleet question, and it exists because the fleet question has a failure the + * single-dir one doesn't: an agent whose index you could not measure. Reporting + * that agent as under budget is how a sweep reports a confident clean over a + * fleet that is not the fleet. + * + * So the four states are deliberately not three: + * NO_MEMORY_DIR no memory dir at all — no session has ever run there. + * DECLARED, never swept, never a finding. Keeps the + * denominator honest without manufacturing a daily amber. + * MISSING the dir exists but the index is absent or unreadable. + * FAIL-CLOSED — sessions run here with no index at all, and + * that is a finding, not an absence. + * OVER / OK actually measured. + * + * `sweptCount` counts only OVER and OK. MISSING is a finding but not a + * measurement, so it cannot prop up the denominator. A run that measured + * nothing returns `verdict: "nothing-swept"` — never `"clean"`. Findings + * dominate: a one-agent fleet whose index is MISSING has zero measured agents, + * and calling that "nothing-swept" would bury the loudest instance of the exact + * failure this function exists to catch. + * + * Caller contract: `bytes` MUST be LF-normalized UTF-8 bytes. A raw + * `statSync().size` on a Windows working tree reads +1 per line and would flip + * agents over the cap for their line endings alone. + * + * @param {object} input + * @param {Array<{agent: string, path?: string|null, bytes?: number|null, dirExists?: boolean}>} input.rows + * @param {number} [input.budgetBytes] defaults to DEFAULT_INDEX_BUDGET_BYTES + * @returns {{budgetBytes: number, rows: Array, findings: Array, + * sweptCount: number, declaredCount: number, overCount: number, + * missingCount: number, verdict: "clean"|"findings"|"nothing-swept"}} + */ +export function classifyMemoryIndexSizes({ rows, budgetBytes } = {}) { + const budget = + Number.isFinite(budgetBytes) && budgetBytes > 0 ? Math.floor(budgetBytes) : DEFAULT_INDEX_BUDGET_BYTES; + const out = []; + const findings = []; + + for (const raw of Array.isArray(rows) ? rows : []) { + const agent = asString(raw?.agent) || "(unnamed agent)"; + const path = typeof raw?.path === "string" ? raw.path : null; + const bytes = Number.isFinite(raw?.bytes) ? Number(raw.bytes) : null; + + if (raw?.dirExists === false) { + out.push({ + agent, path, bytes: null, + state: MEMORY_SIZE_STATES.NO_MEMORY_DIR, + overBy: null, headroom: null, + message: `${agent}: no memory dir — not swept (no session has run here)`, + }); + continue; + } + + if (bytes === null) { + const message = + `${agent}: index MISSING or unreadable at ${path ?? "(unknown path)"} — the memory dir ` + + `exists, so sessions run here with NO index at all. An unmeasurable agent cannot be ` + + `reported as under budget.`; + out.push({ + agent, path, bytes: null, + state: MEMORY_SIZE_STATES.MISSING, + overBy: null, headroom: null, message, + }); + findings.push({ type: "memory-index-missing", severity: "warn", agent, path, message }); + continue; + } + + if (bytes > budget) { + const overBy = bytes - budget; + const message = + `${agent}: index is ${bytes} bytes vs the ${budget}-byte cap — ${overBy} over; ` + + `${LOADER_TRUNCATION_SENTENCE}.`; + out.push({ + agent, path, bytes, + state: MEMORY_SIZE_STATES.OVER, + overBy, headroom: 0, message, + }); + findings.push({ + type: "memory-index-over-budget", + severity: "warn", + agent, path, bytes, budgetBytes: budget, overBy, message, + }); + continue; + } + + out.push({ + agent, path, bytes, + state: MEMORY_SIZE_STATES.OK, + overBy: null, headroom: budget - bytes, message: null, + }); + } + + const countOf = (state) => out.filter((r) => r.state === state).length; + const sweptCount = countOf(MEMORY_SIZE_STATES.OK) + countOf(MEMORY_SIZE_STATES.OVER); + return { + budgetBytes: budget, + rows: out, + findings, + sweptCount, + declaredCount: countOf(MEMORY_SIZE_STATES.NO_MEMORY_DIR), + overCount: countOf(MEMORY_SIZE_STATES.OVER), + missingCount: countOf(MEMORY_SIZE_STATES.MISSING), + verdict: findings.length > 0 ? "findings" : sweptCount === 0 ? "nothing-swept" : "clean", + }; +} + /** * Nearest existing memory slug to `name` by slug-token Jaccard — for a "did you * mean ?" repair candidate. Returns null when nothing clears the threshold diff --git a/test/memory-integrity.test.mjs b/test/memory-integrity.test.mjs index 1a458e4..53b9815 100644 --- a/test/memory-integrity.test.mjs +++ b/test/memory-integrity.test.mjs @@ -6,6 +6,9 @@ import { suggestMemoryRepairs, compactIndexLines, extractIndexLinks, + extractToolReferences, + classifyMemoryIndexSizes, + MEMORY_SIZE_STATES, DEFAULT_INDEX_BUDGET_BYTES, } from "../lib/memory-integrity.mjs"; @@ -216,3 +219,80 @@ test("compactIndexLines: never severs a second markdown or wiki link mid-way", ( assert.ok(compacted.includes("[Primary](a.md)")); assert.ok(compacted.endsWith("…")); }); + +// The budget is pinned to a LITERAL, not asserted relative to itself. The bug +// this replaces was `Math.floor(24.4 * 1024)` = 24,985 — a check written +// against `DEFAULT_INDEX_BUDGET_BYTES + 1` passes just as happily on the wrong +// constant, which is why it never caught it. +test("index budget is 24,400 decimal bytes — not 24.4 KiB", () => { + assert.equal(DEFAULT_INDEX_BUDGET_BYTES, 24_400); + const at = lintMemoryIntegrity({ indexText: INDEX, files: FILES, indexByteLength: 24_400 }); + assert.equal(at.findings.filter((f) => f.type === "index-over-budget").length, 0); + const over = lintMemoryIntegrity({ indexText: INDEX, files: FILES, indexByteLength: 24_401 }); + assert.equal(over.findings.filter((f) => f.type === "index-over-budget").length, 1); +}); + +test("extractToolReferences: dedupes to first line, skips URLs and placeholders", () => { + const text = [ + "- [Ops](a.md) — run scripts/rollup.mjs nightly", + "- [Again](b.md) — scripts/rollup.mjs, same tool", + "- [Docs](c.md) — https://example.com/thing.py is not ours", + "- [Tmpl](d.md) — call .sh with your own name", + ].join("\n"); + const refs = extractToolReferences(text); + assert.deepEqual(refs, [{ token: "scripts/rollup.mjs", line: 1 }]); + assert.deepEqual(extractToolReferences(""), []); +}); + +test("phantom-tool fires only on a resolver that says provably-absent", () => { + const idx = "- [Ops](feedback_ship_early.md) — run scripts/ghost.mjs weekly"; + const phantom = (r) => + lintMemoryIntegrity({ indexText: idx, files: FILES, toolResolver: r }).findings.filter( + (f) => f.type === "phantom-tool", + ); + assert.equal(phantom(() => false).length, 1); + assert.equal(phantom(() => false)[0].detail.token, "scripts/ghost.mjs"); + assert.equal(phantom(() => true).length, 0); + assert.equal(phantom(() => null).length, 0); // can't judge → no finding + assert.equal(phantom(() => { throw new Error("fs blew up"); }).length, 0); // fail-soft + // No resolver at all: the check does not run, and nothing throws. + assert.equal( + lintMemoryIntegrity({ indexText: idx, files: FILES }).findings.filter((f) => f.type === "phantom-tool").length, + 0, + ); +}); + +test("classifyMemoryIndexSizes: an unmeasurable agent is a finding, never a clean row", () => { + const r = classifyMemoryIndexSizes({ + rows: [ + { agent: "alpha", path: "/a/MEMORY.md", bytes: 1000 }, + { agent: "beta", path: "/b/MEMORY.md", bytes: 24_401 }, + { agent: "gamma", path: "/c/MEMORY.md", bytes: null }, // dir exists, index unreadable + { agent: "delta", dirExists: false }, // never held a session + ], + }); + assert.deepEqual(r.rows.map((x) => x.state), [ + MEMORY_SIZE_STATES.OK, + MEMORY_SIZE_STATES.OVER, + MEMORY_SIZE_STATES.MISSING, + MEMORY_SIZE_STATES.NO_MEMORY_DIR, + ]); + assert.equal(r.sweptCount, 2); // MISSING is a finding, not a measurement + assert.equal(r.declaredCount, 1); // NO_MEMORY_DIR: declared, not swept, not a finding + assert.equal(r.findings.length, 2); // over-budget + missing + assert.equal(r.rows[1].overBy, 1); + assert.equal(r.verdict, "findings"); +}); + +test("classifyMemoryIndexSizes: zero measured agents is nothing-swept, not clean", () => { + assert.equal(classifyMemoryIndexSizes({ rows: [] }).verdict, "nothing-swept"); + assert.equal(classifyMemoryIndexSizes({}).verdict, "nothing-swept"); + assert.equal( + classifyMemoryIndexSizes({ rows: [{ agent: "solo", dirExists: false }] }).verdict, + "nothing-swept", + ); + // ...but findings DOMINATE: a one-agent fleet whose index is missing has zero + // measured agents, and "nothing-swept" would bury the loudest instance of the + // failure this classifier exists to catch. + assert.equal(classifyMemoryIndexSizes({ rows: [{ agent: "solo", bytes: null }] }).verdict, "findings"); +}); From 55c1f0729286aa2bb83d0b656d1f744717c40bc6 Mon Sep 17 00:00:00 2001 From: u00dxk2 Date: Sat, 29 Aug 2026 12:27:28 -0600 Subject: [PATCH 2/3] chore: gitignore tmp/ The bus listener writes tmp/.bus-events.jsonl in whatever repo it runs from, and those events carry portfolio internals. This is a public repo; one stray `git add -A` is all it would take. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CkTF1JryMkGt4Qx2A2vHEh --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 203f48d..4adabe4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc node_modules/ .reviews/ +tmp/ From 64074db6e3785b9bec9d6adf86008b636feebcd5 Mon Sep 17 00:00:00 2001 From: u00dxk2 Date: Sat, 29 Aug 2026 13:22:19 -0600 Subject: [PATCH 3/3] =?UTF-8?q?fix(memory-integrity):=20fold=20in=20the=20?= =?UTF-8?q?Codex=20adversarial=20round=20=E2=80=94=207=20findings,=206=20a?= =?UTF-8?q?s=20written,=201=20adapted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one reproduced against 3cfff31 before it was touched; the tests carry the numbers each returned. 1. The README quickstart measured the index with `statSync().size`, which on a CRLF working tree is +1 byte per line — the documented integration put a valid 24,400-byte index over the cap it had just been pinned to. Measure the text you read, LF-normalized. JSDoc said "on disk"; it now says what the number is. 2. `scripts\foo.py` extracted as `foo.py`, and `file:bar.py` as `bar.py`: the extractor invented tokens the index never contained, and a resolver handed the basename could "prove" absent a file that exists. Backslash is now a separator; a token preceded by `:` is skipped. 3. ADAPTED. `bytes: -1` classified OK with 24,401 bytes of headroom; 1.5 and NaN likewise. A measurement is now a non-negative safe integer; anything else is MISSING, fail-closed. Zero stays a measurement — an empty index is under the cap — and the JSDoc says how that differs from the lint's `indexRead`, which asks whether there was anything to lint. 4. `dirExists: false` alongside a real `bytes` discarded the measurement and hid an over-budget index. The measurement wins; it is what was observed. 5. Two rows named `a` counted as two agents; an empty name became a clean "(unnamed agent)"; a null row manufactured a MISSING finding whose message asserted a directory exists. Rows with no identity, and duplicates, are now `malformedCount` — skipped, never measured, never a finding. 6. `TOOL_REF_RE` was quadratic on a long line with no match: 30,000 hyphens took 2.5 s, on an input the size of the budget this library polices. Segments are bounded and a linear pre-check skips lines with no candidate extension. 50,000 hyphens: 0 ms. 7. The README claimed the lint finds phantom tools while its quickstart passed no resolver, so the check never ran; and an async resolver returned a Promise, never `false`, so it silently never fired. The row now says opt-in and synchronous, the quickstart shows a resolver, and a thenable throws a TypeError — a caller error, not a data ambiguity. 194 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CkTF1JryMkGt4Qx2A2vHEh --- README.md | 16 +++++-- lib/memory-integrity.mjs | 76 +++++++++++++++++++++++++++------- test/memory-integrity.test.mjs | 76 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1c2a88a..22ac7ca 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ both fixes are in the history. | Artifact | What it does | How to adopt | |---|---|---| | [`lib/snippet-redact.mjs`](./lib/snippet-redact.mjs) | Redacts secret-shaped text (API keys, JWTs, DB URIs, PEM blocks…) at the output boundary of any recall/search path, with named shape tokens (`[redacted:github-token]`) so hits stay findable. Defense-in-depth, not DLP — see [Coverage and limits](#coverage-and-limits). | **Use as-is** — vendor the one file (zero deps, pure function) | -| [`lib/memory-integrity.mjs`](./lib/memory-integrity.mjs) | Zero-LLM integrity pass over a markdown agent-memory dir (MEMORY.md-style index + per-fact files + `[[wiki-links]]` — the Claude Code auto-memory shape): dead links, silent merges, over-budget index, orphans, near-duplicates, and index lines naming a tool that doesn't exist; plus a backlink graph, **suggest-only** repairs, and a fleet-wide index-size classifier where an agent you couldn't measure is a finding rather than a clean row. | **Use as-is** — vendor the one file; wire a thin CLI to your memory dir | +| [`lib/memory-integrity.mjs`](./lib/memory-integrity.mjs) | Zero-LLM integrity pass over a markdown agent-memory dir (MEMORY.md-style index + per-fact files + `[[wiki-links]]` — the Claude Code auto-memory shape): dead links, silent merges, over-budget index, orphans, near-duplicates, and (opt-in, with a synchronous resolver you supply) index lines naming a script that resolves to no file; plus a backlink graph, **suggest-only** repairs, and a fleet-wide index-size classifier where an agent you couldn't measure is a finding rather than a clean row. | **Use as-is** — vendor the one file; wire a thin CLI to your memory dir | | [`lib/secret_redaction.py`](./lib/secret_redaction.py) | The same output-boundary redaction for Python recall paths (agent-memory layers, log excerpting) — a faithful port of `snippet-redact.mjs`, kept in sync; extracted while proposing this boundary upstream to a Python memory framework ([mem0ai/mem0#6817](https://github.com/mem0ai/mem0/issues/6817)). | **Use as-is** — vendor the one file (stdlib-only); `python lib/secret_redaction.py` runs its self-check | | [`lib/capability-grant.mjs`](./lib/capability-grant.mjs) | Scoped, single-use, TTL-bounded capability grants for human-gated agent actions: an approval relayed through a chat/bus message is not authorization, so a direct human "go" mints a grant bound to the sha256 of one exact command, honored once. Fail-closed: any ambiguity falls through to your normal permission prompt. | **Reference logic** — pure and complete as logic, and **not an authorization boundary on its own**. You supply: complete, non-bypassable mediation (no execution path reaches the action except through the hook); an authenticated *and* authorized minting principal, minting outside any agent session; a grant store, class policy and audit log the agent cannot write or delete; random ids; scope and class resolution; a trusted clock; atomic consume-before-execute; executing the same captured value that was checked; binding or independently trusting mutable context (cwd, PATH, executable resolution, environment, shell, referenced files) — a command hash does not bind a command's *effect* when those can change; and a fallback to your normal prompt. Budget real work here, not a wrapper | | [`lib/stale-basis.mjs`](./lib/stale-basis.mjs) | One staleness chain for tracker/memory items — newest of the declared *signal* dates (fields stamped only when an item was actually looked at), with bulk-write `updated` timestamps deliberately excluded so a mass edit can't silently re-date the whole tracker. Verdicts name which basis won. | **Use as-is** — vendor the one file; import it from EVERY reader (two hand-rolled copies of a staleness chain will drift) | @@ -77,10 +77,20 @@ const files = fs content: fs.readFileSync(path.join(MEMORY_DIR, e.name), "utf8"), })); +const indexText = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf8") : ""; + const input = { - indexText: fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf8") : "", + indexText, files, - indexByteLength: fs.existsSync(indexPath) ? fs.statSync(indexPath).size : 0, + // The cap is 24,400 LF-normalized UTF-8 bytes. Measure the text you read, not + // `statSync().size`: on a CRLF working tree the file is +1 byte per line, and + // a valid index goes over the line for its line endings alone. + indexByteLength: Buffer.byteLength(indexText.replace(/\r\n/g, "\n"), "utf8"), + // Opt-in, and it must be synchronous (a Promise is never `false`, and the lib + // throws rather than let the check go silently inert). Return `false` only + // when you can PROVE the file is absent — here, "not in this repo"; if your + // tools live elsewhere too, widen the search or return null. + toolResolver: (token) => fs.existsSync(path.resolve(token)), }; const { findings, swept, coverage } = lintMemoryIntegrity(input); diff --git a/lib/memory-integrity.mjs b/lib/memory-integrity.mjs index 5433115..61ef1d7 100644 --- a/lib/memory-integrity.mjs +++ b/lib/memory-integrity.mjs @@ -186,13 +186,25 @@ export function extractWikiLinks(content) { // this file stays fs-free and the check stays testable without a disk. // A tool-shaped token: optional relative path prefix + basename with a script -// extension. Placeholders (), URLs, and glob-ish tokens are excluded. -const TOOL_REF_RE = /(?])((?:\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:mjs|cjs|ts|sh|py))(?![\w/])/g; +// extension, `/` or `\` separated. Placeholders (), URL/URI schemes, and +// glob-ish tokens are excluded. A token preceded by `:` is skipped so that +// `file:foo.py` / `mailto:foo.py` do not shed their scheme and surface as a +// bare `foo.py` the resolver then "proves" absent — the extractor must never +// invent a token the index does not contain. Path segments are bounded: an +// unbounded `[\w.-]+` backtracks quadratically on a long line with no match +// (30,000 hyphens took 2.5 s), and an over-budget index is exactly the input +// this library exists to handle. +const TOOL_REF_RE = + /(?:])((?:\.{1,2}[/\\])?(?:[\w.-]{1,128}[/\\])*[\w.-]{1,128}\.(?:mjs|cjs|ts|sh|py))(?![\w/\\])/g; +/** Linear pre-check: a line with no candidate extension never reaches the matcher. */ +const TOOL_EXT_HINT_RE = /\.(?:mjs|cjs|ts|sh|py)(?![\w/\\])/; /** * Extract tool/script references from index text. Pure. Dedupes by token, - * keeping the first line each appears on. Lines carrying a URL are skipped - * whole (too false-positive-prone), as are `` tokens. + * keeping the first line each appears on. Lines carrying an http(s) URL are + * skipped whole (too false-positive-prone), as are `` tokens. + * Tokens are returned as written — `scripts\foo.py` stays `scripts\foo.py`, + * never its basename — so the resolver judges the reference the index made. * @param {string} text * @returns {Array<{token: string, line: number}>} */ @@ -202,6 +214,7 @@ export function extractToolReferences(text) { const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; + if (!TOOL_EXT_HINT_RE.test(line)) continue; if (/https?:\/\//.test(line)) continue; // URL-bearing line: skip entirely let m; TOOL_REF_RE.lastIndex = 0; @@ -220,13 +233,18 @@ export function extractToolReferences(text) { * @param {object} input * @param {string} input.indexText index file content (conventionally MEMORY.md) * @param {Array<{name: string, content?: string}>} input.files topic files (the index is skipped by name) - * @param {number} [input.indexByteLength] byte size of the index on disk + * @param {number} [input.indexByteLength] LF-normalized UTF-8 byte length of + * `indexText` — NOT `statSync().size`, which on a CRLF working tree reads +1 + * per line and puts a valid index over the cap for its line endings alone * @param {number} [input.budgetBytes] load budget (default 24,400 bytes) * @param {(token: string) => boolean|null|undefined} [input.toolResolver] * OPT-IN existence check for tool/script names in the index. `true` = exists, * `false` = provably absent (the only value that yields a finding), * `null`/`undefined`/throw = can't judge → NO finding. Omit it and the - * phantom-tool check does not run at all. + * phantom-tool check does not run at all. MUST be synchronous: a Promise is + * never `false`, so an async resolver would make the check permanently + * inert while looking enabled — that is a caller error, not a data + * ambiguity, and it throws a TypeError rather than failing soft. * @returns {{findings: Array<{type: string, severity: "warn"|"info", message: string, detail?: object}>, swept: boolean, coverage: {reached: number, skipped: number, malformed: number, contentless: number, indexExcluded: number, indexRead: boolean}}} * `reached` = non-index entries with a string name AND string content (empty * string counts — it was supplied). `malformed` = null, or a missing/non-string @@ -329,6 +347,12 @@ export function lintMemoryIntegrity({ indexText, files, indexByteLength, budgetB } catch { exists = null; } + if (exists && typeof exists.then === "function") { + throw new TypeError( + "lintMemoryIntegrity: toolResolver must be synchronous — a Promise is never `false`, " + + "so an async resolver silently disables the phantom-tool check", + ); + } if (exists === false) { findings.push({ type: "phantom-tool", @@ -582,29 +606,52 @@ export const MEMORY_SIZE_STATES = Object.freeze({ * and calling that "nothing-swept" would bury the loudest instance of the exact * failure this function exists to catch. * - * Caller contract: `bytes` MUST be LF-normalized UTF-8 bytes. A raw - * `statSync().size` on a Windows working tree reads +1 per line and would flip - * agents over the cap for their line endings alone. + * Inventory faults are counted in `malformedCount` and skipped — never + * classified. A row that is not an object, has no non-empty string `agent`, or + * repeats an `agent` already seen cannot be a clean measurement, and it cannot + * be a MISSING finding either: MISSING asserts that a dir exists and sessions + * run there, and nothing is known about a row with no identity. A duplicate is + * the same fault from the other side — it would count one agent twice. + * + * Caller contract: `bytes` is a non-negative safe integer of LF-normalized + * UTF-8 bytes. A raw `statSync().size` on a Windows working tree reads +1 per + * line and would flip agents over the cap for their line endings alone. + * Anything that is not such an integer — negative, fractional, NaN, Infinity — + * is not a measurement and classifies as MISSING (fail-closed), because a `-1` + * accepted as a byte count sits under budget with 24,401 bytes of headroom. + * Zero IS a measurement: an empty index is under the cap. (That differs from + * `lintMemoryIntegrity`'s `indexRead`, which asks whether there was anything + * to lint — a different question.) A row carrying both `dirExists: false` and + * a real `bytes` is contradictory; the measurement wins, because it is the + * thing that was actually observed. * * @param {object} input * @param {Array<{agent: string, path?: string|null, bytes?: number|null, dirExists?: boolean}>} input.rows * @param {number} [input.budgetBytes] defaults to DEFAULT_INDEX_BUDGET_BYTES * @returns {{budgetBytes: number, rows: Array, findings: Array, * sweptCount: number, declaredCount: number, overCount: number, - * missingCount: number, verdict: "clean"|"findings"|"nothing-swept"}} + * missingCount: number, malformedCount: number, + * verdict: "clean"|"findings"|"nothing-swept"}} */ export function classifyMemoryIndexSizes({ rows, budgetBytes } = {}) { const budget = Number.isFinite(budgetBytes) && budgetBytes > 0 ? Math.floor(budgetBytes) : DEFAULT_INDEX_BUDGET_BYTES; const out = []; const findings = []; + const seen = new Set(); + let malformedCount = 0; for (const raw of Array.isArray(rows) ? rows : []) { - const agent = asString(raw?.agent) || "(unnamed agent)"; - const path = typeof raw?.path === "string" ? raw.path : null; - const bytes = Number.isFinite(raw?.bytes) ? Number(raw.bytes) : null; + const agent = raw && typeof raw === "object" && typeof raw.agent === "string" ? raw.agent.trim() : ""; + if (!agent || seen.has(agent)) { + malformedCount++; + continue; + } + seen.add(agent); + const path = typeof raw.path === "string" ? raw.path : null; + const bytes = Number.isSafeInteger(raw.bytes) && raw.bytes >= 0 ? raw.bytes : null; - if (raw?.dirExists === false) { + if (raw.dirExists === false && bytes === null) { out.push({ agent, path, bytes: null, state: MEMORY_SIZE_STATES.NO_MEMORY_DIR, @@ -663,6 +710,7 @@ export function classifyMemoryIndexSizes({ rows, budgetBytes } = {}) { declaredCount: countOf(MEMORY_SIZE_STATES.NO_MEMORY_DIR), overCount: countOf(MEMORY_SIZE_STATES.OVER), missingCount: countOf(MEMORY_SIZE_STATES.MISSING), + malformedCount, verdict: findings.length > 0 ? "findings" : sweptCount === 0 ? "nothing-swept" : "clean", }; } diff --git a/test/memory-integrity.test.mjs b/test/memory-integrity.test.mjs index 53b9815..5e32be5 100644 --- a/test/memory-integrity.test.mjs +++ b/test/memory-integrity.test.mjs @@ -296,3 +296,79 @@ test("classifyMemoryIndexSizes: zero measured agents is nothing-swept, not clean // failure this classifier exists to catch. assert.equal(classifyMemoryIndexSizes({ rows: [{ agent: "solo", bytes: null }] }).verdict, "findings"); }); + +// The Codex adversarial round on PR #2. Each case below reproduced against the +// first version of this port; the numbers in the comments are what it returned. +test("extractToolReferences keeps Windows separators and never sheds a URI scheme", () => { + // Was: `scripts\foo.py` → `foo.py`. A resolver handed the basename can + // "prove" absent a file that exists — the extractor inventing a token. + assert.deepEqual(extractToolReferences(String.raw`run scripts\foo.py nightly`), [ + { token: String.raw`scripts\foo.py`, line: 1 }, + ]); + // Was: `file:bar.py` → `bar.py`, contradicting the "URLs excluded" comment. + assert.deepEqual(extractToolReferences("see file:bar.py or mailto:baz.sh"), []); + // Backticks and link parens are fine delimiters; a placeholder is not a token. + assert.deepEqual(extractToolReferences("- run `scripts/a.mjs` then [b](tools/b.ts) via .sh"), [ + { token: "scripts/a.mjs", line: 1 }, + { token: "tools/b.ts", line: 1 }, + ]); +}); + +test("extractToolReferences is linear on a long line with no match", () => { + // Was quadratic: 30,000 hyphens took 2.5 s — on an input the size of the + // index budget this library exists to police. 50,000 is ~5 s unfixed; the + // 200 ms bound leaves a 25x margin for a slow CI runner. + const t = Date.now(); + assert.deepEqual(extractToolReferences("-".repeat(50_000)), []); + assert.ok(Date.now() - t < 200, `took ${Date.now() - t} ms`); +}); + +test("an async toolResolver throws instead of silently disabling the check", () => { + // Was: 0 findings, no error — a check that looked enabled and could not fire. + assert.throws( + () => lintMemoryIntegrity({ indexText: "run scripts/ghost.mjs", files: FILES, toolResolver: async () => false }), + TypeError, + ); +}); + +test("classifyMemoryIndexSizes: an impossible byte count is unmeasured, zero is measured", () => { + // Was: -1 → OK with 24,401 bytes of headroom; 1.5 and NaN → OK / clean. + const r = classifyMemoryIndexSizes({ + rows: [ + { agent: "neg", bytes: -1 }, + { agent: "frac", bytes: 1.5 }, + { agent: "nan", bytes: NaN }, + { agent: "empty", bytes: 0 }, + ], + }); + assert.deepEqual(r.rows.map((x) => x.state), [ + MEMORY_SIZE_STATES.MISSING, + MEMORY_SIZE_STATES.MISSING, + MEMORY_SIZE_STATES.MISSING, + MEMORY_SIZE_STATES.OK, + ]); + assert.equal(r.sweptCount, 1); + assert.equal(r.verdict, "findings"); +}); + +test("classifyMemoryIndexSizes: a measurement beats a contradicting dirExists:false", () => { + // Was: the row went NO_MEMORY_DIR, bytes → null, an over-budget index hidden. + const r = classifyMemoryIndexSizes({ rows: [{ agent: "hidden", dirExists: false, bytes: 24_401 }] }); + assert.equal(r.rows[0].state, MEMORY_SIZE_STATES.OVER); + assert.equal(r.verdict, "findings"); +}); + +test("classifyMemoryIndexSizes: rows with no identity are malformed, not measured and not findings", () => { + // Was: a duplicate agent counted twice (sweptCount 2); an empty name became a + // clean "(unnamed agent)"; a null row manufactured a MISSING finding whose + // message asserted a dir exists — about a row nothing is known about. + const r = classifyMemoryIndexSizes({ + rows: [{ agent: "a", bytes: 1 }, { agent: "a", bytes: 2 }, { agent: "", bytes: 5 }, null, "str", { bytes: 7 }], + }); + assert.equal(r.sweptCount, 1); + assert.equal(r.malformedCount, 5); + assert.equal(r.findings.length, 0); + assert.equal(r.verdict, "clean"); + // All malformed → nothing swept, never clean. + assert.equal(classifyMemoryIndexSizes({ rows: [null, { agent: "" }] }).verdict, "nothing-swept"); +});