subagent: seed inherited policy events at creation

The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch.

Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults.

Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly.

This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
This commit is contained in:
Tianyi Cui
2026-07-28 21:31:17 +08:00
parent afa38c4b2f
commit cfceb8452b
55 changed files with 415 additions and 1371 deletions

View File

@@ -146,8 +146,6 @@ 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 {
@@ -526,23 +524,6 @@ export class SessionQuerySqlite extends SessionQueryService {
}
}
/** The shared header column bindings both session tables lead with. */
private static _headerBindings(
header: SessionHeader,
): [string, number, number, string | null, string | null, number | null, number | null, string | null, string | null] {
return [
header.id,
header.version,
header.createdAt,
header.cwd ?? null,
header.parentSession ?? null,
header.seedLength ?? null,
header.delegationDepth ?? null,
header.sandboxMode ?? null,
header.approvalPolicy ?? null,
]
}
private _replacePersistedSession(
entry: ObservedSession,
revision: SessionPersistenceRevision,
@@ -552,9 +533,19 @@ 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, sandbox_mode, approval_policy, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(...SessionQuerySqlite._headerBindings(entry.header), revision, generation)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
entry.header.id,
entry.header.version,
entry.header.createdAt,
entry.header.cwd ?? null,
entry.header.parentSession ?? null,
entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null,
revision,
generation,
)
const insert = db.prepare(`
INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -578,9 +569,20 @@ 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, sandbox_mode, approval_policy, fingerprint, persisted, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(...SessionQuerySqlite._headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
entry.header.id,
entry.header.version,
entry.header.createdAt,
entry.header.cwd ?? null,
entry.header.parentSession ?? null,
entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null,
entry.fingerprint,
persisted ? 1 : 0,
generation,
)
const insert = db.prepare(`
INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -669,7 +671,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, sandbox_mode, approval_policy, generation
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
FROM temp.live_sessions
WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
@@ -679,7 +681,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, sandbox_mode, approval_policy, generation
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
FROM persisted_sessions
WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
@@ -738,8 +740,6 @@ 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,8 +762,6 @@ 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,
@@ -872,8 +870,6 @@ 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 {
@@ -885,8 +881,6 @@ 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 },
}
}

View File

@@ -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 = 6
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -117,8 +117,6 @@ 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
@@ -148,8 +146,6 @@ 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

View File

@@ -225,40 +225,6 @@ 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',
createUserMessage({ 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')