From 9aaa4a871f1723aca26cf8f1bf20945e116dcf7d Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 23:31:01 +0800 Subject: [PATCH] policy: reject out-of-range seed boundaries; carry baselines through session-query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (ds-review-bot on #623): - overrideOf (both knobs) rejects a seedLength past the log end before slicing: a malformed durable boundary would otherwise empty the own-switch slice until the log outgrew it, letting a wide baseline shadow a REAL later tightening. Malformed durable metadata fails loud, never open. - The session-query derived index carries the two baseline fields end to end: schema columns on both session tables (SESSION_QUERY_SQLITE_SCHEMA _VERSION 6 — derived, rebuilds in place), inserts, header selects, the candidates CTE, rowHeader, sameHeader, and the cross-source assertSessionHeadersCompatible — so a search hit's header keeps the child's inherited confinement and conflicting live/persisted baselines reject. Red-first: out-of-range seedLength tests in both policy suites; baseline round-trip and live/persisted baseline-conflict tests in the session-query sqlite suite. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../sandbox-policy/src/session-mode.ts | 9 ++++- .../sandbox-policy/tests/policy.spec.ts | 11 ++++++ .../session-query-sqlite/src/index.ts | 26 ++++++++++---- .../session-query-sqlite/src/schema.ts | 6 +++- .../session-query-sqlite/tests/sqlite.spec.ts | 34 +++++++++++++++++++ .../session-query/src/sources.ts | 2 ++ packages/ui/user-approval/src/index.ts | 9 ++++- .../ui/user-approval/tests/approval.spec.ts | 8 +++++ 10 files changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d12247385..dae508be7b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,7 +1862,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:226`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:233`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5ed0806257..575d4e7648 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -256,7 +256,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:241`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:248`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 93ba463d30..c74def8e5e 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -78,7 +78,14 @@ export function sandboxOverrideOf(session: Session): SandboxMode | undefined { if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) } - const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) + // A boundary past the log would make the own-switch slice empty until the + // log grows past it — a wide baseline would then shadow a REAL later + // tightening. Malformed durable metadata fails loud, never fails open. + const seedLength = session.header.seedLength ?? 0 + if (seedLength > session.events.length) { + throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) + } + const own = effectiveSandboxMode(session.events.slice(seedLength)) return own ?? baseline as SandboxMode } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index a0924c0d99..d69b5b0e2b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -218,4 +218,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') }) + + it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { + const ctx = await mounted() + // A malformed durable seedLength beyond the log would make the own-switch + // slice empty until the log grows past it — a wide baseline would then + // shadow a REAL later tightening. Fail loud at the durable boundary. + const child = inheritedSession('sess-inherit-oob', { sandboxMode: 'danger-full-access', seedLength: 100 }) + setSandboxMode(child, 'read-only') + + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/) + }) }) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0c073ccea5..2f86e29919 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -146,6 +146,8 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null + sandbox_mode: string | null + approval_policy: string | null } interface SearchRow extends SessionHeaderRow { @@ -533,8 +535,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -543,6 +545,8 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.sandboxMode ?? null, + entry.header.approvalPolicy ?? null, revision, generation, ) @@ -569,8 +573,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -579,6 +583,8 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.sandboxMode ?? null, + entry.header.approvalPolicy ?? null, entry.fingerprint, persisted ? 1 : 0, generation, @@ -671,7 +677,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -681,7 +687,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -740,6 +746,8 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, + ps.sandbox_mode AS sandbox_mode, + ps.approval_policy AS approval_policy, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -762,6 +770,8 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, + ls.sandbox_mode AS sandbox_mode, + ls.approval_policy AS approval_policy, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -870,6 +880,8 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) + && a.sandboxMode === b.sandboxMode + && a.approvalPolicy === b.approvalPolicy } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -881,6 +893,8 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.sandbox_mode === null ? {} : { sandboxMode: row.sandbox_mode }, + ...row.approval_policy === null ? {} : { approvalPolicy: row.approval_policy }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..1bd832aa47 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -117,6 +117,8 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + sandbox_mode TEXT, + approval_policy TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -146,6 +148,8 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + sandbox_mode TEXT, + approval_policy TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 2fdd0b1e89..624cec4029 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -220,6 +220,40 @@ describe('SQLite session search', () => { .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) + it('round-trips the inherited policy baselines through search headers', async () => { + // A delegated child's header carries the sandbox/approval baselines; the + // derived index must return them — a consumer resuming from a search hit + // would otherwise rebuild a child without its inherited confinement. + const ctx = await liveContext({ path: ':memory:' }) + const session = ctx.sessions.create(SessionId('live-baseline'), { + meta: { cwd: '/work', createdAt: 10, sandboxMode: 'read-only', approvalPolicy: 'never' }, + }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'baseline needle' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + + const result = await ctx.sessionQuery.searchSessions({ query: 'needle' }) + expect(result.items[0]?.header).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) + const events = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle' }) + expect(events.session).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) + }) + + it('rejects live/persisted sources whose policy baselines conflict', async () => { + const shared = header('baseline-conflict', 10, { sandboxMode: 'read-only' }) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + ctx.sessions.create(shared.id, { + seed: messageEvents('live needle'), + meta: { createdAt: 10, sandboxMode: 'danger-full-access' }, + }) + + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + it('searches all surfaces by default and applies metadata before ranking', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const parent = SessionId('parent') diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts index f1bb64275f..2daccb6b28 100644 --- a/packages/session-query/session-query/src/sources.ts +++ b/packages/session-query/session-query/src/sources.ts @@ -17,6 +17,8 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade || a.parentSession !== b.parentSession || a.seedLength !== b.seedLength || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) + || a.sandboxMode !== b.sandboxMode + || a.approvalPolicy !== b.approvalPolicy ) { throw new SessionQueryError( `session source headers conflict for session "${a.id}"`, diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 2692bff997..06ea9ae9ef 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -162,7 +162,14 @@ export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) } - const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) + // A boundary past the log would make the own-switch slice empty until the + // log grows past it — a baseline would then shadow a REAL later switch. + // Malformed durable metadata fails loud, never fails open. + const seedLength = session.header.seedLength ?? 0 + if (seedLength > session.events.length) { + throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) + } + const own = effectiveApprovalPolicy(session.events.slice(seedLength)) return own ?? baseline as ApprovalPolicy } diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 76a4afef5f..431423f959 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -655,4 +655,12 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(ctx.approval.overrideOf(child)).toBe('never') }) + + it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-oob', { approvalPolicy: 'ask', seedLength: 100 }) + setApprovalPolicy(child, 'never') + + expect(() => ctx.approval.overrideOf(child)).toThrow(/seedLength/) + }) })