Преглед изворни кода

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 пре 1 месец
родитељ
комит
9aaa4a871f

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

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

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

+ 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.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
   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 },
   }
 }
 

+ 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'
 
 /** 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

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

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

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

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