From 8321021c54d670126ce3b2969d5deb880b4b0c2a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 12 Sep 2026 07:45:40 -0400 Subject: [PATCH] fix(memory): classify directory traversal failures --- scripts/lib/memory-vault.js | 18 +++++++-- tests/lib/memory-read-completeness.test.js | 44 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/lib/memory-vault.js b/scripts/lib/memory-vault.js index e8f855974..e79cb1a73 100644 --- a/scripts/lib/memory-vault.js +++ b/scripts/lib/memory-vault.js @@ -443,6 +443,12 @@ function publicMemoryFileError(error) { }; } +function incompleteMemoryLookupError() { + const error = new Error('Memory lookup is incomplete. Inspect the authorized vault before retrying.'); + error.code = 'ECC_MEMORY_INCOMPLETE'; + return error; +} + function readMemoryFiles(options = {}) { const roots = options.roots || resolveVaultRoots(options); const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES); @@ -461,7 +467,13 @@ function readMemoryFiles(options = {}) { break; } const root = assertMemoryRootSafe(roots, scope); - const walked = walkMemoryRoot(root, MAX_FILES - visitedCount); + let walked; + try { + walked = walkMemoryRoot(root, MAX_FILES - visitedCount); + } catch { + // Directory open/read/close failures cannot establish a complete lookup. + throw incompleteMemoryLookupError(); + } visitedCount += walked.visitedCount; truncated = truncated || walked.truncated; skippedSymlinkCount += walked.skippedSymlinkCount; @@ -660,9 +672,7 @@ function readMemoryById(id, options = {}) { : null; const loaded = readMemoryFiles(options); if (loaded.truncated || loaded.invalidFileCount > 0) { - const error = new Error('Memory lookup is incomplete. Inspect the authorized vault before retrying.'); - error.code = 'ECC_MEMORY_INCOMPLETE'; - throw error; + throw incompleteMemoryLookupError(); } const matches = loaded.entries .filter(entry => entry.memory.id === memoryId) diff --git a/tests/lib/memory-read-completeness.test.js b/tests/lib/memory-read-completeness.test.js index f85ee08bf..de03aee1f 100644 --- a/tests/lib/memory-read-completeness.test.js +++ b/tests/lib/memory-read-completeness.test.js @@ -75,6 +75,50 @@ async function main() { fs.openSync = open; } }); + function failTraversal(roots, phase, fn) { + const open = fs.opendirSync; + let closed = false; + fs.opendirSync = (directory, ...args) => { + if (directory !== roots.project) return open(directory, ...args); + const fail = () => { + const error = new Error('Synthetic private directory detail.'); + error.code = 'EACCES'; + throw error; + }; + if (phase === 'open') return fail(); + const handle = open(directory, ...args); + return { + readSync: () => phase === 'read' ? fail() : handle.readSync(), + closeSync: () => { + handle.closeSync(); closed = true; + if (phase === 'close') fail(); + }, + }; + }; + try { fn(); } finally { + fs.opendirSync = open; + if (phase !== 'open') assert.equal(closed, true); + } + } + for (const phase of ['open', 'read', 'close']) { + check(`direct read classifies directory ${phase} failure`, ({ roots, read }) => { + failTraversal(roots, phase, () => { + assert.throws(read, error => error.code === 'ECC_MEMORY_INCOMPLETE' + && !error.message.includes('Synthetic private directory detail.')); + }); + }); + check(`MCP read classifies directory ${phase} failure`, ({ roots, mcp }) => { + failTraversal(roots, phase, () => { + const result = mcp(); assert.equal(result.isError, true); + const error = JSON.parse(result.content[0].text).error; + assert.equal(error.code, 'MEMORY_READ_INCOMPLETE'); + assert.equal(error.message.includes('Synthetic private directory detail.'), false); + }); + }); + } + check('unreadable traversal cannot establish missing memory', ({ roots, read }) => { + failTraversal(roots, 'open', () => incomplete(() => read('mem_synthetic_missing'))); + }); check('MCP incomplete lookup has a distinct bounded error', ({ mcp, truncate }) => { truncate(); const result = mcp(); assert.equal(result.isError, true); const error = JSON.parse(result.content[0].text).error;