From def026e5bc90c3b9eaecf4360c41e19a6b113b88 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 17:06:10 +0800 Subject: [PATCH] fix: harden session trace authorization --- .../tool-session-query/src/index.ts | 78 +++++++++--- .../tests/tool-session-query.spec.ts | 119 ++++++++++++++++++ 2 files changed, 183 insertions(+), 14 deletions(-) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 92be54d46b..e7b8b1ef88 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -131,6 +131,18 @@ interface AuthorizedDescendant { readonly descendants: Array } +interface DescendantProjectionFrame { + readonly node: SessionLineageNode + readonly target: Array + readonly next: DescendantProjectionFrame | undefined +} + +interface DescendantVisit { + readonly node: AuthorizedDescendant | null + readonly depth: number + readonly next: DescendantVisit | undefined +} + const SESSION_SEARCH_PARAMETERS = { query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, @@ -688,7 +700,7 @@ function recordAuthorized(record: SessionRecord, caller: Caller): boolean { } function headerAuthorized(header: SessionHeader, caller: Caller): boolean { - if (header.id === caller.id) return true + if (header.id === caller.id) return header.cwd === caller.header.cwd return caller.header.cwd !== undefined && header.cwd === caller.header.cwd } @@ -764,20 +776,60 @@ function authorizeDescendants( nodes: readonly SessionLineageNode[], caller: Caller, ): Array { - return nodes.map((node) => { - if (!recordAuthorized(node.session, caller)) return null - return { - record: node.session, - descendants: authorizeDescendants(node.descendants, caller), + const result: Array = [] + let pending: DescendantProjectionFrame | undefined + for (const node of [...nodes].reverse()) { + pending = { node, target: result, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + if (!recordAuthorized(current.node.session, caller)) { + current.target.push(null) + continue } - }) + const projected: AuthorizedDescendant = { + record: current.node.session, + descendants: [], + } + current.target.push(projected) + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + target: projected.descendants, + next: pending, + } + } + } + return result +} + +function * visitDescendants( + nodes: readonly (AuthorizedDescendant | null)[], +): Generator { + let pending: DescendantVisit | undefined + for (const node of [...nodes].reverse()) { + pending = { node, depth: 0, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + yield current + if (current.node === null) continue + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + depth: current.depth + 1, + next: pending, + } + } + } } function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { const ids: SessionIdValue[] = [] - for (const node of nodes) { - if (node === null) continue - ids.push(node.record.header.id, ...descendantIds(node.descendants)) + for (const { node } of visitDescendants(nodes)) { + if (node !== null) ids.push(node.record.header.id) } return ids } @@ -865,7 +917,7 @@ function formatSessionTrace( if (ancestorBoundary) lines.push('- [outside workspace boundary]') lines.push('', 'Descendants:') if (descendants.length === 0) lines.push('- none') - else renderDescendants(lines, descendants, titles, 0) + else renderDescendants(lines, descendants, titles) return lines.join('\n') } @@ -873,9 +925,8 @@ function renderDescendants( lines: string[], nodes: readonly (AuthorizedDescendant | null)[], titles: CompleteTitleMap, - depth: number, ): void { - for (const node of nodes) { + for (const { node, depth } of visitDescendants(nodes)) { const indent = ' '.repeat(depth) if (node === null) { lines.push(`${indent}- [outside workspace subtree]`) @@ -883,7 +934,6 @@ function renderDescendants( } const id = node.record.header.id lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) - renderDescendants(lines, node.descendants, titles, depth + 1) } } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index ae62161f14..8aa8376d67 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -16,6 +16,7 @@ import SessionQueryService, { type SessionEventSearchHit, type SessionEventSearchPage, type SessionEventSearchRequest, + type SessionLineageNode, type SessionSearchExecContext, type SessionSearchHit, type SessionSearchPage, @@ -450,6 +451,65 @@ describe('workspace authority and lineage redaction', () => { expect(output).not.toContain('hidden-grandchild-secret') }) + it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'branch-target', '/work', 20) + const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{ + kind: 'id', + values: [target.id], + }]) + if (targetRecord === undefined) throw new Error('expected target record') + const firstId = SessionId('branch-first') + const nestedId = SessionId('branch-nested') + const hiddenId = SessionId('branch-hidden-secret') + const hiddenDescendantId = SessionId('branch-hidden-descendant-secret') + const lastId = SessionId('branch-last') + const descendants: SessionLineageNode[] = [ + { + session: { ...targetRecord, header: header(firstId, '/work', 30) }, + descendants: [ + { + session: { ...targetRecord, header: header(nestedId, '/work', 40) }, + descendants: [], + }, + { + session: { ...targetRecord, header: header(hiddenId, '/outside', 50) }, + descendants: [{ + session: { ...targetRecord, header: header(hiddenDescendantId, '/work', 60) }, + descendants: [], + }], + }, + ], + }, + { + session: { ...targetRecord, header: header(lastId, '/work', 70) }, + descendants: [], + }, + ] + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: targetRecord, + ancestors: [], + descendants, + complete: true, + root: targetRecord, + }) + const titleReads: SessionIdValue[] = [] + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation((sessionId) => { + titleReads.push(sessionId) + return Promise.resolve({ session: header(sessionId, '/work') }) + }) + + const output = text(await mounted.call('session_trace', { session_id: target.id })) + expect(output.slice(output.indexOf('Descendants:'))).toBe([ + 'Descendants:', + '- branch-first — untitled | 1970-01-01T00:00:00.030Z | live', + ' - branch-nested — untitled | 1970-01-01T00:00:00.040Z | live', + ' - [outside workspace subtree]', + '- branch-last — untitled | 1970-01-01T00:00:00.070Z | live', + ].join('\n')) + expect(titleReads).toEqual([target.id, firstId, nestedId, lastId]) + }) + it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => { const mounted = await mount() const root = createSession(mounted.ctx, 'visible-root', '/work', 5) @@ -550,6 +610,30 @@ describe('workspace authority and lineage redaction', () => { expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') expect(text(titled)).not.toContain('secret moved title') }) + + it('rejects a default self read when its same-id observation moved after caller capture', async () => { + const mounted = await mount() + const secret = mounted.caller.append( + 'context/message', + { + content: [{ type: 'text', text: 'same-id moved secret' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + { surfaceOp: 'append' }, + ) + const window = await mounted.ctx.sessionQuery.readEvent({ + sessionId: mounted.caller.id, + seq: secret.seq, + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({ + ...window, + session: header(mounted.caller.id, '/outside'), + }) + + const denied = await mounted.call('session_event_read', { seq: secret.seq }) + expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(denied)).not.toContain('same-id moved secret') + }) }) describe('search paging, prior-history bounds, titles, and cancellation', () => { @@ -797,6 +881,41 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }) describe('trace and exact read rendering', () => { + it('renders a deeply nested lineage without recursive consumer traversal', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'deep-target', '/work') + const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{ + kind: 'id', + values: [target.id], + }]) + if (targetRecord === undefined) throw new Error('expected target record') + const depth = 3_000 + let descendants: SessionLineageNode[] = [] + for (let index = depth; index >= 1; index -= 1) { + descendants = [{ + session: { + ...targetRecord, + header: header(`deep-${index}`, '/work', index), + }, + descendants, + }] + } + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: targetRecord, + ancestors: [], + descendants, + complete: true, + root: targetRecord, + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(sessionId => Promise.resolve({ + session: header(sessionId, '/work'), + })) + + const output = text(await mounted.call('session_trace', { session_id: target.id })) + expect(output).toContain('Descendants:\n- deep-1 —') + expect(output).toContain(`${' '.repeat(depth - 1)}- deep-${depth} —`) + }) + it('renders every event relationship sequence and a UTC target timestamp', async () => { const mounted = await mount() const session = createSession(mounted.ctx, 'relationships', '/work')