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: 31b917f7edc08182c72b1e4c0ed403a155dc477f
README.zh.md: 81cce8f804f5d6cb4a4915f3aecca278dd357db1
README.md: 6563fbda00c8fc9a22be5f34ffb327d5876e1c05
README.zh.md: f0cd20b3a5c40cb76141ff1fb0b6cd1f5f6b59f5

View File

@@ -18,7 +18,7 @@ The driver follows this sequence:
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and a one-shot PREPENDED `agent/prompt-submit` listener stamps the captured values through `stampOverride` (both services consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request; prepending puts it before veto-capable listeners, so a denying UserPromptSubmit hook cannot close the first turn without the stamp; its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing. Only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each capture folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
## Cancellation and ownership

View File

@@ -18,7 +18,7 @@
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)``ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——再由一个一次性、前置安装的 `agent/prompt-submit` 监听器通过 `stampOverride` 盖章写入捕获值(两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派)。锚定在子 agent 的第一个轮次内,使盖章事件被包围在轮次内(具备持久性)并先于第一次请求;前置安装使其位于具备否决能力的监听器之前,因此作出拒绝的 UserPromptSubmit 钩子无法在未盖章的情况下关闭第一个轮次;盖章事件在日志中位于 fork 初始内容携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析陈旧初始内容的时序。只复制覆盖链,因此未切换过的父 agent 不盖任何章,子 agent 继续跟随实时部署默认值。嵌套按构造即可组合:每次捕获折叠的都是发起委派的会话已经盖过章的日志(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)``ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader``sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
## 取消与所有权

View File

@@ -100,16 +100,17 @@ export async function startInProcessRun(
subagentDepth: childDepth,
}
// Policy inheritance, read half: capture the parent's sandbox/approval
// OVERRIDES synchronously, before the first await — the delegation moment
// is the semantic snapshot point, and a parent switch racing the child's
// Policy inheritance: capture the parent's sandbox/approval OVERRIDES
// synchronously, before the first await — the delegation moment is the
// semantic snapshot point, and a parent switch racing the child's
// asynchronous creation must belong to the parent's future, not the child.
// Both services are consumed opportunistically — without them, delegation
// stays policy-free.
const sandboxPolicy = parent.ctx.get('sandboxPolicy')
const approval = parent.ctx.get('approval')
const inheritedMode = sandboxPolicy?.overrideOf(parent.session)
const inheritedPolicy = approval?.overrideOf(parent.session)
// The captured values ride the child's creation meta into its immutable
// header, so the baseline is durable from the moment the session exists —
// no first-turn event could survive every crash window (an idle injection
// can persist a complete turn before any prompt turn opens). Both services
// are consumed opportunistically — without them, delegation is policy-free.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
@@ -120,23 +121,6 @@ export async function startInProcessRun(
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
// Write half: stamp the captured overrides once, anchored inside the
// child's FIRST turn (prompt-submit runs after turn/start, before prompt
// assembly) — a bare between-turn append would be crash-tail garbage on
// reload, and stamping here also orders the override after any stale
// switch a fork seed carried, so the ordinary last-event-wins fold
// resolves it. PREPENDED so a veto-capable listener (a denying
// UserPromptSubmit hook) cannot close the first turn without the stamp —
// the stamp must be durable even for a blocked first prompt. One-shot:
// later turns must not re-stamp over a switch the child made itself.
if (inheritedMode !== undefined || inheritedPolicy !== undefined) {
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
disposeInherit()
if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode)
if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy)
return next()
}, { prepend: true })
}
}
const flags = { cancelled: false }
@@ -148,6 +132,8 @@ export async function startInProcessRun(
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...seedLength > 0 ? { seedLength } : {},
...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {},
...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,

View File

@@ -198,16 +198,13 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
// The stamped override is the child's OWN durable, turn-enclosed record:
// after turn/start, before the first model request snapshot.
const events = child.session.events
const turnStart = events.findIndex(e => e.type === 'turn/start')
const mode = events.findIndex(e => e.type === 'sandbox/mode')
const policy = events.findIndex(e => e.type === 'approval/policy')
const header = events.findIndex(e => e.type === 'request/header')
expect(mode).toBeGreaterThan(turnStart)
expect(policy).toBeGreaterThan(turnStart)
expect(header).toBeGreaterThan(mode)
// The inherited baseline is part of the child's IMMUTABLE header —
// durable from the creation moment, with no first-turn timing window
// (a crash after any persisted turn still resumes with the baseline).
expect(child.session.header.sandboxMode).toBe('read-only')
expect(child.session.header.approvalPolicy).toBe('never')
// The log stays free of stamped events: the header is the one home.
expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 })
// What the enforcing families resolve for the child, end to end.
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
// Inheritance reads the parent log, never writes it.
@@ -216,6 +213,33 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
await run.dispose()
})
it('the baseline is durable BEFORE any child turn exists (the injection-turn crash window)', async () => {
// The review scenario: a SessionStart-style idle injection can persist a
// complete turn before the first prompt turn opens. The baseline must
// already be durable then — it is, because it rides the creation-time
// header, not a first-turn event.
const script: Script = []
const { parent } = await setupWalled(script)
script.push(
() => {
setSandboxMode(parent.session, 'read-only')
return textResponse('staged')
},
textResponse('child done'),
)
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
const child = run.localAgent as Agent
// Assert on the HEADER immediately after publication — before the child's
// first turn has run (run.result not yet awaited). An idle injection
// persisting a turn now would carry the baseline with it.
expect(child.session.header.sandboxMode).toBe('read-only')
await run.result
await run.dispose()
})
it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => {
const script: Script = []
const captured: Agent[] = []
@@ -373,10 +397,11 @@ describe('inheritance survives prompt vetoes', () => {
await run.result
const child = run.localAgent as Agent
// The veto closed the first turn promptless, but the stamp is inside that
// turn regardless — a later resume must not fall back to the deployment
// default just because the first prompt was blocked.
expect(overrideEvents(child)).toEqual({ sandbox: 1, approval: 0 })
// The veto closed the first turn promptless, but the baseline rides the
// creation-time header — no listener ordering can starve it, and a later
// resume must not fall back to the deployment default just because the
// first prompt was blocked.
expect(child.session.header.sandboxMode).toBe('read-only')
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
await run.dispose()
@@ -399,7 +424,9 @@ describe('inheritance guards (must hold before AND after the fix)', () => {
// workspace-write (the deployment default) really allowed the write…
expect(await readFile(allowed, 'utf8')).toBe('fine')
// …and nothing froze that default into the child log.
// …and nothing froze that default into the child header or log.
expect(child.session.header.sandboxMode).toBeUndefined()
expect(child.session.header.approvalPolicy).toBeUndefined()
expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 })
await run.dispose()