Jelajahi Sumber

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.
kingwl 2 bulan lalu
induk
melakukan
9aaa4a871f

+ 1 - 1
docs/config-catalog.md

@@ -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`
 
 

+ 1 - 1
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)
 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)
 
 

+ 8 - 1
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)) {
   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
 }
 }
 
 

+ 11 - 0
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.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/)
+  })
 })
 })

+ 20 - 6
packages/session-query/session-query-sqlite/src/index.ts

@@ -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)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+        (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation)
+      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)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation)
+      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 },
   }
   }
 }
 }
 
 

+ 5 - 1
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'
 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

+ 34 - 0
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 }] })
       .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')

+ 2 - 0
packages/session-query/session-query/src/sources.ts

@@ -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}"`,

+ 8 - 1
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)) {
   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
 }
 }
 
 

+ 8 - 0
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')
     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/)
+  })
 })
 })