fix: harden session trace authorization
This commit is contained in:
@@ -131,6 +131,18 @@ interface AuthorizedDescendant {
|
|||||||
readonly descendants: Array<AuthorizedDescendant | null>
|
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 = {
|
const SESSION_SEARCH_PARAMETERS = {
|
||||||
query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' },
|
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.' },
|
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 {
|
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
|
return caller.header.cwd !== undefined && header.cwd === caller.header.cwd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,20 +776,60 @@ function authorizeDescendants(
|
|||||||
nodes: readonly SessionLineageNode[],
|
nodes: readonly SessionLineageNode[],
|
||||||
caller: Caller,
|
caller: Caller,
|
||||||
): Array<AuthorizedDescendant | null> {
|
): Array<AuthorizedDescendant | null> {
|
||||||
return nodes.map((node) => {
|
const result: Array<AuthorizedDescendant | null> = []
|
||||||
if (!recordAuthorized(node.session, caller)) return null
|
let pending: DescendantProjectionFrame | undefined
|
||||||
return {
|
for (const node of [...nodes].reverse()) {
|
||||||
record: node.session,
|
pending = { node, target: result, next: pending }
|
||||||
descendants: authorizeDescendants(node.descendants, caller),
|
}
|
||||||
|
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[] {
|
function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] {
|
||||||
const ids: SessionIdValue[] = []
|
const ids: SessionIdValue[] = []
|
||||||
for (const node of nodes) {
|
for (const { node } of visitDescendants(nodes)) {
|
||||||
if (node === null) continue
|
if (node !== null) ids.push(node.record.header.id)
|
||||||
ids.push(node.record.header.id, ...descendantIds(node.descendants))
|
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
@@ -865,7 +917,7 @@ function formatSessionTrace(
|
|||||||
if (ancestorBoundary) lines.push('- [outside workspace boundary]')
|
if (ancestorBoundary) lines.push('- [outside workspace boundary]')
|
||||||
lines.push('', 'Descendants:')
|
lines.push('', 'Descendants:')
|
||||||
if (descendants.length === 0) lines.push('- none')
|
if (descendants.length === 0) lines.push('- none')
|
||||||
else renderDescendants(lines, descendants, titles, 0)
|
else renderDescendants(lines, descendants, titles)
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,9 +925,8 @@ function renderDescendants(
|
|||||||
lines: string[],
|
lines: string[],
|
||||||
nodes: readonly (AuthorizedDescendant | null)[],
|
nodes: readonly (AuthorizedDescendant | null)[],
|
||||||
titles: CompleteTitleMap,
|
titles: CompleteTitleMap,
|
||||||
depth: number,
|
|
||||||
): void {
|
): void {
|
||||||
for (const node of nodes) {
|
for (const { node, depth } of visitDescendants(nodes)) {
|
||||||
const indent = ' '.repeat(depth)
|
const indent = ' '.repeat(depth)
|
||||||
if (node === null) {
|
if (node === null) {
|
||||||
lines.push(`${indent}- [outside workspace subtree]`)
|
lines.push(`${indent}- [outside workspace subtree]`)
|
||||||
@@ -883,7 +934,6 @@ function renderDescendants(
|
|||||||
}
|
}
|
||||||
const id = node.record.header.id
|
const id = node.record.header.id
|
||||||
lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`)
|
lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`)
|
||||||
renderDescendants(lines, node.descendants, titles, depth + 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import SessionQueryService, {
|
|||||||
type SessionEventSearchHit,
|
type SessionEventSearchHit,
|
||||||
type SessionEventSearchPage,
|
type SessionEventSearchPage,
|
||||||
type SessionEventSearchRequest,
|
type SessionEventSearchRequest,
|
||||||
|
type SessionLineageNode,
|
||||||
type SessionSearchExecContext,
|
type SessionSearchExecContext,
|
||||||
type SessionSearchHit,
|
type SessionSearchHit,
|
||||||
type SessionSearchPage,
|
type SessionSearchPage,
|
||||||
@@ -450,6 +451,65 @@ describe('workspace authority and lineage redaction', () => {
|
|||||||
expect(output).not.toContain('hidden-grandchild-secret')
|
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 () => {
|
it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => {
|
||||||
const mounted = await mount()
|
const mounted = await mount()
|
||||||
const root = createSession(mounted.ctx, 'visible-root', '/work', 5)
|
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(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||||
expect(text(titled)).not.toContain('secret moved title')
|
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', () => {
|
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', () => {
|
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 () => {
|
it('renders every event relationship sequence and a UTC target timestamp', async () => {
|
||||||
const mounted = await mount()
|
const mounted = await mount()
|
||||||
const session = createSession(mounted.ctx, 'relationships', '/work')
|
const session = createSession(mounted.ctx, 'relationships', '/work')
|
||||||
|
|||||||
Reference in New Issue
Block a user