فهرست منبع

fix: harden session trace authorization

Hypatia May 2 ماه پیش
والد
کامیت
def026e5bc

+ 64 - 14
packages/session-query/tool-session-query/src/index.ts

@@ -131,6 +131,18 @@ interface AuthorizedDescendant {
   readonly descendants: Array<AuthorizedDescendant | null>
 }
 
+interface DescendantProjectionFrame {
+  readonly node: SessionLineageNode
+  readonly target: Array<AuthorizedDescendant | null>
+  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<AuthorizedDescendant | null> {
-  return nodes.map((node) => {
-    if (!recordAuthorized(node.session, caller)) return null
-    return {
-      record: node.session,
-      descendants: authorizeDescendants(node.descendants, caller),
+  const result: Array<AuthorizedDescendant | null> = []
+  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<DescendantVisit> {
+  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)
   }
 }
 

+ 119 - 0
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')