policy: reject out-of-range seed boundaries; carry baselines through session-query

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.
This commit is contained in:
kingwl
2026-07-26 23:31:01 +08:00
parent 99f5fab7bc
commit 9aaa4a871f
10 changed files with 98 additions and 11 deletions

View File

@@ -1862,7 +1862,7 @@ export interface Config {
export type ApprovalPolicy = 'ask' | 'never' 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` ## `@deepseek-ai/dsh-web`

View File

@@ -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) 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) ## `ctx.bash` — `BashExecutor` (abstract seam)

View File

@@ -78,7 +78,14 @@ export function sandboxOverrideOf(session: Session): SandboxMode | undefined {
if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { if (!SANDBOX_MODES.includes(baseline as SandboxMode)) {
throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) 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 return own ?? baseline as SandboxMode
} }

View File

@@ -218,4 +218,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => {
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).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/)
})
}) })

View File

@@ -146,6 +146,8 @@ interface SessionHeaderRow {
parent_session: string | null parent_session: string | null
seed_length: number | null seed_length: number | null
delegation_depth: number | null delegation_depth: number | null
sandbox_mode: string | null
approval_policy: string | null
} }
interface SearchRow extends SessionHeaderRow { interface SearchRow extends SessionHeaderRow {
@@ -533,8 +535,8 @@ export class SessionQuerySqlite extends SessionQueryService {
const db = this._requireDb() const db = this._requireDb()
db.prepare(` db.prepare(`
INSERT INTO persisted_sessions INSERT INTO persisted_sessions
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
entry.header.id, entry.header.id,
entry.header.version, entry.header.version,
@@ -543,6 +545,8 @@ export class SessionQuerySqlite extends SessionQueryService {
entry.header.parentSession ?? null, entry.header.parentSession ?? null,
entry.header.seedLength ?? null, entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null, entry.header.delegationDepth ?? null,
entry.header.sandboxMode ?? null,
entry.header.approvalPolicy ?? null,
revision, revision,
generation, generation,
) )
@@ -569,8 +573,8 @@ export class SessionQuerySqlite extends SessionQueryService {
const db = this._requireDb() const db = this._requireDb()
db.prepare(` db.prepare(`
INSERT INTO temp.live_sessions INSERT INTO temp.live_sessions
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
entry.header.id, entry.header.id,
entry.header.version, entry.header.version,
@@ -579,6 +583,8 @@ export class SessionQuerySqlite extends SessionQueryService {
entry.header.parentSession ?? null, entry.header.parentSession ?? null,
entry.header.seedLength ?? null, entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null, entry.header.delegationDepth ?? null,
entry.header.sandboxMode ?? null,
entry.header.approvalPolicy ?? null,
entry.fingerprint, entry.fingerprint,
persisted ? 1 : 0, persisted ? 1 : 0,
generation, generation,
@@ -671,7 +677,7 @@ export class SessionQuerySqlite extends SessionQueryService {
const db = this._requireDb() const db = this._requireDb()
const live = db.prepare( const live = db.prepare(
`SELECT `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 FROM temp.live_sessions
WHERE id = ?`, WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
@@ -681,7 +687,7 @@ export class SessionQuerySqlite extends SessionQueryService {
if (persistenceBinding.service !== undefined) { if (persistenceBinding.service !== undefined) {
const persisted = db.prepare( const persisted = db.prepare(
`SELECT `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 FROM persisted_sessions
WHERE id = ?`, WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
@@ -740,6 +746,8 @@ function selectedDocumentsSql(): { sql: string } {
ps.parent_session AS parent_session, ps.parent_session AS parent_session,
ps.seed_length AS seed_length, ps.seed_length AS seed_length,
ps.delegation_depth AS delegation_depth, ps.delegation_depth AS delegation_depth,
ps.sandbox_mode AS sandbox_mode,
ps.approval_policy AS approval_policy,
0 AS live, 0 AS live,
1 AS persisted, 1 AS persisted,
CAST(pd.seq AS INTEGER) AS seq, CAST(pd.seq AS INTEGER) AS seq,
@@ -762,6 +770,8 @@ function selectedDocumentsSql(): { sql: string } {
ls.parent_session AS parent_session, ls.parent_session AS parent_session,
ls.seed_length AS seed_length, ls.seed_length AS seed_length,
ls.delegation_depth AS delegation_depth, ls.delegation_depth AS delegation_depth,
ls.sandbox_mode AS sandbox_mode,
ls.approval_policy AS approval_policy,
1 AS live, 1 AS live,
CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted,
CAST(ld.seq AS INTEGER) AS seq, CAST(ld.seq AS INTEGER) AS seq,
@@ -870,6 +880,8 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
&& a.parentSession === b.parentSession && a.parentSession === b.parentSession
&& a.seedLength === b.seedLength && a.seedLength === b.seedLength
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
&& a.sandboxMode === b.sandboxMode
&& a.approvalPolicy === b.approvalPolicy
} }
function rowHeader(row: SessionHeaderRow): SessionHeader { 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.parent_session === null ? {} : { parentSession: row.parent_session as SessionId },
...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.seed_length === null ? {} : { seedLength: row.seed_length },
...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
...row.sandbox_mode === null ? {} : { sandboxMode: row.sandbox_mode },
...row.approval_policy === null ? {} : { approvalPolicy: row.approval_policy },
} }
} }

View File

@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */ /** 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. */ /** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -117,6 +117,8 @@ function ensurePersistentSchema(db: DatabaseSync): void {
parent_session TEXT, parent_session TEXT,
seed_length INTEGER, seed_length INTEGER,
delegation_depth INTEGER, delegation_depth INTEGER,
sandbox_mode TEXT,
approval_policy TEXT,
revision TEXT NOT NULL, revision TEXT NOT NULL,
generation INTEGER NOT NULL generation INTEGER NOT NULL
) STRICT ) STRICT
@@ -146,6 +148,8 @@ function ensureTemporarySchema(db: DatabaseSync): void {
parent_session TEXT, parent_session TEXT,
seed_length INTEGER, seed_length INTEGER,
delegation_depth INTEGER, delegation_depth INTEGER,
sandbox_mode TEXT,
approval_policy TEXT,
fingerprint TEXT NOT NULL, fingerprint TEXT NOT NULL,
persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)),
generation INTEGER NOT NULL generation INTEGER NOT NULL

View File

@@ -220,6 +220,40 @@ describe('SQLite session search', () => {
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) .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 () => { it('searches all surfaces by default and applies metadata before ranking', async () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 })
const parent = SessionId('parent') const parent = SessionId('parent')

View File

@@ -17,6 +17,8 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade
|| a.parentSession !== b.parentSession || a.parentSession !== b.parentSession
|| a.seedLength !== b.seedLength || a.seedLength !== b.seedLength
|| (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0)
|| a.sandboxMode !== b.sandboxMode
|| a.approvalPolicy !== b.approvalPolicy
) { ) {
throw new SessionQueryError( throw new SessionQueryError(
`session source headers conflict for session "${a.id}"`, `session source headers conflict for session "${a.id}"`,

View File

@@ -162,7 +162,14 @@ export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined
if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) 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 return own ?? baseline as ApprovalPolicy
} }

View File

@@ -655,4 +655,12 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => {
expect(ctx.approval.overrideOf(child)).toBe('never') 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/)
})
}) })