subagent: carry inherited policy overrides in the child session header

Review fix (ds-review-bot critical #2 on #623): the first-turn event stamp
had a durability hole no turn anchoring can close — an idle SessionStart-
style injection persists a complete one-shot turn before any prompt turn
opens, so a crash in that window left a resumable-looking child with no
inherited policy, falling back to a possibly wider deployment default.

The captured overrides now ride the child's creation meta into its
immutable SessionHeader (sandboxMode/approvalPolicy, neutral strings at the
session boundary — the delegationDepth precedent), durable from the moment
the session exists: no listener ordering can starve the baseline and no
crash window can lose it. overrideOf(session) on both policy services
resolves fold(events past header.seedLength) ?? header baseline, validating
against the closed vocabulary on read; stampOverride and the prompt-submit
listener machinery are deleted. The header field rides both persistence
backends (JSONL header line; SQLite sessions columns, SCHEMA_VERSION 11 —
pre-release, no migration). pty-local reads through overrideOf so PTY
spawns see the baseline too.

Red-first: header-durability-before-any-turn test (the injection crash
window shape), baseline/seed-boundary/closed-vocabulary contract tests in
both service suites; the real-wall suite (race, veto, fork stale-seed,
grandchild) re-anchored on header assertions and green. The Agent Note's
Alternatives now records the superseded event-stamping iteration with the
review evidence; bilingual docs updated.
This commit is contained in:
kingwl
2026-07-26 18:16:45 +08:00
parent 166628c0b3
commit c53e9c90db
41 changed files with 387 additions and 264 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 3e6d8082766d718b0cfcb94369f48a283713fcd2
README.zh.md: 2f84bd4971ddbe0f15e78ef7f5f069350ff43f1f
README.md: 733a14141a8a67728b026c7a19d18266b61cbf6a
README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5

View File

@@ -19,7 +19,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `ctx.sandboxPolicy.overrideOf(session)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
- `ctx.sandboxPolicy.overrideOf(session)` — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules.

View File

@@ -19,7 +19,7 @@
- `ctx.sandboxPolicy.defaultMode``ctx.sandboxPolicy.workspaceRoot``resolve()` 使用的部署默认值与回退根。
- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold最后一次切换胜出没有则为 `undefined`),在 `resolve()` 内使用。
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
- `ctx.sandboxPolicy.overrideOf(session)``ctx.sandboxPolicy.stampOverride(child, mode)`:委派继承的两半:仅折叠本身(绝不包含部署默认值),以及通过 `setSandboxMode` 写入捕获的覆盖项,子 agent 已折叠出该值时跳过。进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使发起委派的父级收紧后的模式约束其子 agent参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
- `ctx.sandboxPolicy.overrideOf(session)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件只要其值不在该封闭词汇中Session 与其配套组件拥有周围的存储与轮次封闭规则。

View File

@@ -19,7 +19,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
import { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
@@ -100,38 +100,35 @@ export class SandboxPolicyService extends Service {
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
const { session } = request
return {
mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode,
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
}
}
/**
* A session's sandbox-mode OVERRIDE — the fold alone, never the deployment
* default. The read half of delegation inheritance: the subagent driver
* captures this synchronously at delegation, so a parent switch racing the
* child's asynchronous creation belongs to the parent's future, not to the
* child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to fold.
* @returns the last switched mode, or `undefined` for a never-switched session.
* A session's sandbox-mode OVERRIDE — the override chain alone, never the
* deployment default: the fold of the session's OWN switches (events past
* the seed boundary — a fork seed's stale parent switch is subsumed by the
* baseline captured after it), else the header's inherited delegation
* baseline. The subagent driver stamps `overrideOf(parent.session)` into
* each child's creation meta, so the chain collapses one level per
* delegation and a tightened parent binds children at any depth
* ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to resolve.
* @returns the effective override, or `undefined` for a session following
* the deployment default.
* @throws when the durable header baseline is outside the closed mode
* vocabulary (a corrupt or foreign log; durable-boundary validation).
*/
overrideOf(session: Session): SandboxMode | undefined {
return effectiveSandboxMode(session.events)
}
/**
* Stamp a captured override onto a child session through the canonical
* write path — the write half of delegation inheritance: a child agent runs
* under the policy its delegating parent was switched to, not under the
* (possibly wider) deployment default. A child whose log (e.g. a fork seed)
* already folds to the mode is left untouched. Callers must append inside
* an open child turn — a bare between-turn event is crash-tail garbage on
* reload.
* @param child - the child session the override is appended to.
* @param mode - the captured {@link overrideOf} value to stamp.
*/
stampOverride(child: Session, mode: SandboxMode): void {
if (effectiveSandboxMode(child.events) === mode) return
setSandboxMode(child, mode)
const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0))
if (own !== undefined) return own
const baseline = session.header.sandboxMode
if (baseline === undefined) return undefined
if (!SANDBOX_MODES.includes(baseline as SandboxMode)) {
throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`)
}
return baseline as SandboxMode
}
}

View File

@@ -143,41 +143,56 @@ describe('the sandbox/mode session kit', () => {
})
})
describe('delegation inheritance (overrideOf + stampOverride)', () => {
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
describe('delegation inheritance (overrideOf over the header baseline)', () => {
/** A session whose header carries the delegation-inheritance baseline. */
function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session {
const sessionId = SessionId(id)
return new Session(sessionId, undefined, {
version: 0,
id: sessionId,
createdAt: 0,
...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode },
...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength },
})
}
it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => {
it('overrideOf folds the session log and never falls back to the deployment default', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const parent = session('sess-inherit-parent')
setSandboxMode(parent, 'workspace-write')
setSandboxMode(parent, 'read-only')
expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only')
// undefined, NOT the deployment default — a child stamped with the
// undefined, NOT the deployment default — a child whose header froze the
// default would stop following the LIVE default across resumes.
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
})
it('stampOverride appends the captured mode through the canonical write path', async () => {
const ctx = await mounted()
const child = session('sess-inherit-child')
it('overrideOf reads the header baseline when the log has no own switch', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' })
ctx.sandboxPolicy.stampOverride(child, 'read-only')
const stamped = modeEvents(child)
expect(stamped).toHaveLength(1)
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
// resolve() consumes the same chain, so enforcement sees the baseline.
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only')
})
it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => {
it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
// The fork seed carried the parent's OLD workspace-write switch (one
// event, so seedLength 1); the delegation-time baseline is read-only.
const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 })
setSandboxMode(child, 'workspace-write')
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
// A switch the child makes ITSELF (after the seed boundary) outranks it.
setSandboxMode(child, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access')
})
it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => {
const ctx = await mounted()
const child = session('sess-inherit-dedup-child')
// A fork seed can already carry the parent's switch; stamping again would
// append a redundant event on every delegation.
setSandboxMode(child, 'read-only')
const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' })
ctx.sandboxPolicy.stampOverride(child, 'read-only')
expect(modeEvents(child)).toHaveLength(1)
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/)
})
})