From bac2816ecac5f439e6cee6d6f39ed6c1fddcafc5 Mon Sep 17 00:00:00 2001 From: levineam Date: Fri, 14 Aug 2026 14:33:43 -0400 Subject: [PATCH] fix(agent-context): scope session thread notes --- .../scripts/jarvos-mcp.js | 2 - modules/jarvos-agent-context/src/index.js | 18 +++++++-- .../test/agent-context.test.js | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/modules/jarvos-agent-context/scripts/jarvos-mcp.js b/modules/jarvos-agent-context/scripts/jarvos-mcp.js index 66fd1ab5..7a2b4428 100755 --- a/modules/jarvos-agent-context/scripts/jarvos-mcp.js +++ b/modules/jarvos-agent-context/scripts/jarvos-mcp.js @@ -174,7 +174,6 @@ const TOOLS = [ issueIdentifier: { type: 'string', description: 'Issue identifier such as SUP-2219.' }, artifact: { type: 'string', description: 'Artifact pointer such as an issue, branch, note, URL, or file path.' }, project: { type: 'string', description: 'Project tag used when no explicit thread id is provided.' }, - title: { type: 'string', description: 'Explicit note title to read.' }, routeCapability: { type: 'string', description: 'Opaque short-lived route binding issued by the trusted native adapter.' }, maxChars: { type: 'number', description: 'Maximum characters of thread content to return.' }, }, @@ -196,7 +195,6 @@ const TOOLS = [ summary: { type: 'string', description: 'What changed or what the next AI needs to know.' }, decision: { type: 'string', description: 'Latest decision to preserve.' }, nextStep: { type: 'string', description: 'Concrete next action for the next host.' }, - title: { type: 'string', description: 'Explicit note title to write.' }, routeCapability: { type: 'string', description: 'Opaque short-lived route binding issued by the trusted native adapter.' }, }, }, diff --git a/modules/jarvos-agent-context/src/index.js b/modules/jarvos-agent-context/src/index.js index c833a170..9edf125f 100644 --- a/modules/jarvos-agent-context/src/index.js +++ b/modules/jarvos-agent-context/src/index.js @@ -860,11 +860,21 @@ function normalizeThreadKey(input = {}) { } function sessionThreadTitle(input = {}) { - const explicit = firstString(input.title, input.noteTitle); - if (explicit) return sanitizeTitle(explicit); return sanitizeTitle(`${DEFAULT_SESSION_THREAD_PREFIX} - ${normalizeThreadKey(input)}`); } +function hasSessionThreadFrontmatter(markdown) { + const frontmatter = String(markdown || '').match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1]; + if (!frontmatter) return false; + return /^(?:type|subtype):\s*["']?session-thread["']?\s*$/m.test(frontmatter); +} + +function assertSessionThreadNote(markdown, title) { + if (!hasSessionThreadFrontmatter(markdown)) { + throw new Error(`Refusing to access non-session-thread note: ${title}`); + } +} + function stripFrontmatter(markdown) { return String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim(); } @@ -943,6 +953,7 @@ function renderSessionThreadRead(result) { function readSessionThread(input = {}) { const thread = resolveSessionThread(input); const raw = readIfExists(thread.notePath); + if (raw !== null) assertSessionThreadNote(raw, thread.title); const content = raw === null ? '' : boundedMarkdown(stripFrontmatter(raw), Number(input.maxChars || 4000)); const result = { ok: true, @@ -967,6 +978,7 @@ function writeSessionThread(input = {}) { let readBack; try { const existing = readIfExists(thread.notePath); + if (existing !== null) assertSessionThreadNote(existing, thread.title); const existingBody = existing ? stripFrontmatter(existing) : ''; const timestamp = firstString(input.timestamp) || new Date().toISOString(); const entry = formatThreadEntry(input, timestamp); @@ -991,7 +1003,7 @@ function writeSessionThread(input = {}) { createJournalIfMissing: input.createJournalIfMissing !== false, mutationService, }); - if (noteResult.written) readBack = readSessionThread({ ...input, title: thread.title, maxChars: input.maxChars }); + if (noteResult.written) readBack = readSessionThread({ ...input, maxChars: input.maxChars }); } finally { releaseLock(); } diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index 209b070d..bbb0a647 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -385,6 +385,43 @@ test('session thread writes a note, links today journal, and reads across hosts' }); }); +test('session thread access cannot be redirected to arbitrary vault notes', () => { + withTempVault(({ notes, mutationService }) => { + const privateNote = path.join(notes, 'Private Vault Note.md'); + fs.writeFileSync(privateNote, '---\ntype: private-note\n---\nPrivate medical plan.', 'utf8'); + + const read = readSessionThread({ title: 'Private Vault Note', threadId: 'safe-thread' }); + assert.equal(read.found, false); + assert.doesNotMatch(read.markdown, /Private medical plan/); + + writeSessionThread({ + title: 'Private Vault Note', + threadId: 'safe-thread', + summary: 'A safe checkpoint.', + mutationService, + }); + assert.equal(fs.readFileSync(privateNote, 'utf8').includes('A safe checkpoint.'), false); + }); +}); + +test('session thread access rejects namespace collisions without session-thread frontmatter', () => { + withTempVault(({ notes, mutationService }) => { + const privateNote = path.join(notes, 'JarvOS Session Thread - collision.md'); + const original = '---\ntype: private-note\n---\nPrivate collision content.'; + fs.writeFileSync(privateNote, original, 'utf8'); + + assert.throws( + () => readSessionThread({ threadId: 'collision' }), + /Refusing to access non-session-thread note/, + ); + assert.throws( + () => writeSessionThread({ threadId: 'collision', summary: 'Injected checkpoint.', mutationService }), + /Refusing to access non-session-thread note/, + ); + assert.equal(fs.readFileSync(privateNote, 'utf8'), original); + }); +}); + test('session thread appends checkpoints through latest-content transforms', () => { withTempVault(({ mutationService }) => { for (let index = 0; index < 6; index += 1) {