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

@@ -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 packages/subagent/subagent-inprocess/README.md
README.md: 95a45cd7a1f4510601f7f8d8bf396e7262f1a3cf
README.zh.md: c20f5106d0009f9c5507e830361c0fcba54d6280
README.md: 3606799e6d16e80473006f82b834a10953270914
README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15

View File

@@ -18,8 +18,6 @@ The driver follows this sequence:
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. 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 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
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.

View File

@@ -18,8 +18,6 @@
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
子 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))。
## 取消与所有权
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。

View File

@@ -102,17 +102,28 @@ export async function startInProcessRun(
subagentDepth: childDepth,
}
// 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.
// 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.
// Capture before the first await: a later parent switch belongs to the
// parent's future. Appending after the fork prefix makes the captured
// values the child's initial overrides without another storage plane.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
const seed: SessionEvent[] = [...options.seed ?? []]
if (inheritedMode !== undefined) {
seed.push({
type: 'sandbox/mode',
seq: seed.length,
time: Date.now(),
data: { mode: inheritedMode, source: 'delegation' },
})
}
if (inheritedPolicy !== undefined) {
seed.push({
type: 'approval/policy',
seq: seed.length,
time: Date.now(),
data: { policy: inheritedPolicy, source: 'delegation' },
})
}
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
@@ -134,10 +145,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 } : {},
...(options.seed !== undefined || seed.length > 0) ? { seed } : {},
agentOptions,
signal: request.signal,
setup,

View File

@@ -1,39 +1,17 @@
/**
* Policy inheritance from parent to in-process child agents, proven against
* the REAL enforcement wall: a real loop drives a scripted mock MODEL whose
* children hit the real `dsh-fs-sandbox` fence through the real `write` tool,
* and every claim is asserted on physical facts — does the file exist on
* disk, what denial text landed in the child's tool result. Nothing here asks
* the policy service what it WOULD do; the child either writes or is denied.
*
* Red/green anchor for the delegation-bypass gap: a parent switched to
* `read-only` must not mint children that run under the (wider) deployment
* default. The captured overrides ride the child's creation-time header, so
* three review-found timing threats are pinned as distinct shapes: a parent
* switch racing the asynchronous creation, a veto-capable prompt-submit
* listener closing a promptless first turn, and an injection-persisted turn
* before any prompt turn opens (header asserted before the child runs).
*/
/** Policy inheritance through constructor-seeded child session events. */
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -42,130 +20,36 @@ import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
const contexts: Context[] = []
let workspace: string
beforeEach(async () => {
// realpath: macOS tmpdir is symlinked (/var → /private/var); resolve once so
// path assertions and the fence's canonicalization agree on one spelling.
workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
})
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
await rm(workspace, { recursive: true, force: true })
})
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
/**
* The walled composition: real loop + real sandbox-policy home + the real
* confining filesystem backend + the real `write` tool + the approval seam
* (mounted with NO answerer — the in-process child reality). The deployment
* default is deliberately WIDER (`workspace-write`) than the parent's staged
* `read-only` override, so a child that fails to inherit visibly escapes.
*
* The script array is taken by reference and filled by each test AFTER the
* parent exists, so scripted side-effect entries can close over it.
*/
async function setupWalled(script: Script) {
async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
await ctx.plugin(ToolFs)
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }, { cwd: workspace })
const parent = ctx.agentLoop.create(
SessionId('parent'),
{ provider: 'mock', model: 'mock' },
{ cwd: workspace },
)
return { ctx, parent }
}
/** Bare composition: no sandbox, no fs, no approval — delegation must not care. */
async function setupBare(script: Script) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
/**
* Register the delegation scratch tool: delegating from INSIDE an open parent
* turn is exactly the real tool-subagent shape, and it is what makes the
* "user switched while idle, model delegates in the very next turn" fork
* timing constructible (the post-seed switch lives in the still-open turn).
* `fork: true` seeds the child with the caller's completed-turn prefix,
* mirroring the fork provider's slice. `raceSwitch` flips the CALLER's mode
* synchronously after `startInProcessRun`'s synchronous prologue but before
* its creation transaction resolves — the delegation-vs-late-switch race.
*/
function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-full-access'): void {
ctx.tools.register(defineTool({
name: 'delegate',
description: 'delegate a task to an in-process child (test scaffold)',
parameters: { fork: { type: 'boolean', description: 'seed the child with the completed-turn prefix' } },
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
stopReason: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `child:${(value).stopReason}` }],
},
async execute(args, exec) {
const caller = exec.agent
if (caller === undefined) throw new Error('delegate scaffold requires a calling agent')
const events = caller.session.events
const lastEnd = events.findLast(e => e.type === 'turn/end')
const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1)
const starting = startInProcessRun(
{ prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal },
args.fork === true && seed.length > 0 ? { seed } : {},
)
// The caller's turn is still open, so this switch is legal — and it lands
// while the child's creation transaction is pending, strictly before the
// child's first prompt-submit could ever run.
if (raceSwitch !== undefined) setSandboxMode(caller.session, raceSwitch)
const run = await starting
captured.push(run.localAgent as Agent)
const result = await run.result
await run.dispose()
return { stopReason: result.stopReason }
},
}))
}
/** All tool/result texts in a session log, in order. */
function toolResultTexts(agent: Agent): string[] {
return agent.session.events
.filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
.map(e => e.data.message.content
.flatMap(block => block.content)
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(''))
}
/** Count the policy-override events in a session log. */
function overrideEvents(agent: Agent): { sandbox: number; approval: number } {
const events = agent.session.events
return {
sandbox: events.filter(e => e.type === 'sandbox/mode').length,
approval: events.filter(e => e.type === 'approval/policy').length,
}
}
function spawnRequest(parent: Agent) {
return {
prompt: [{ type: 'text' as const, text: 'child task' }],
@@ -174,320 +58,125 @@ function spawnRequest(parent: Agent) {
}
}
describe('sandbox-mode inheritance against the real fs fence', () => {
it('a SPAWN child of a read-only parent is denied by the real fence (no file on disk)', async () => {
function toolResultTexts(agent: Agent): string[] {
return agent.session.events
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
.map(event => event.data.message.content
.flatMap(block => block.content)
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(''))
}
describe('in-process policy inheritance', () => {
it('seeds parent overrides into a spawn child before its first request', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
script.push(
// The switch is staged INSIDE a parent turn — the same turn-enclosed
// anchoring every real switch path (ACP pending switches) uses.
() => {
setSandboxMode(parent.session, 'read-only')
setApprovalPolicy(parent.session, 'never')
return textResponse('staged')
},
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage the session policy' }], source: { kind: 'user' } }))
await parent.whenIdle()
setSandboxMode(parent.session, 'read-only')
setApprovalPolicy(parent.session, 'never')
const parentLogLength = parent.session.events.length
const run = await startInProcessRun(spawnRequest(parent), {})
const result = await run.result
const child = run.localAgent as Agent
// The physical fact: the write never reached the disk.
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// The model-visible fact: the child saw the read-only denial marker.
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
// 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.
expect(parent.session.events.length).toBe(parentLogLength)
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')
},
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
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()
try {
const result = await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
expect(child.session.events.slice(0, 2)).toMatchObject([
{ type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
{ type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(2)
expect(child.session.header.seedLength).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
const request = child.session.events.find(
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
)
expect(request?.data.header.system).toContain('Approval prompts are disabled')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
}
})
it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => {
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
const script: Script = []
const captured: Agent[] = []
const { ctx, parent } = await setupWalled(script)
registerDelegate(ctx, captured)
const blocked = join(workspace, 'fork-blocked.txt')
setSandboxMode(parent.session, 'workspace-write')
const seed = [...parent.session.events]
setSandboxMode(parent.session, 'read-only')
script.push(
// Turn 1: the OLD, wider switch — this one lands in the fork seed.
() => {
setSandboxMode(parent.session, 'workspace-write')
return textResponse('turn one')
},
// Turn 2: the user tightened to read-only, then the model delegates in
// the SAME turn — the switch is in the log but past the seed slice.
() => {
setSandboxMode(parent.session, 'read-only')
return toolCallResponse('d-fork', 'delegate', { fork: true })
},
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('fork child done'),
textResponse('turn two done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn one' }], source: { kind: 'user' } }))
await parent.whenIdle()
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn two: delegate' }], source: { kind: 'user' } }))
await parent.whenIdle()
const child = captured[0] as Agent
// The seed really carried the stale workspace-write switch…
expect(child.session.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true)
// …and the newest parent state still won, on disk and in resolution.
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
})
it('inherits the mode AT delegation, not a parent switch racing child creation', async () => {
const script: Script = []
const captured: Agent[] = []
const { ctx, parent } = await setupWalled(script)
// The delegate scaffold flips the parent to danger-full-access AFTER
// startInProcessRun's synchronous prologue, while the child's creation
// transaction is still pending — the value at delegation is read-only.
registerDelegate(ctx, captured, 'danger-full-access')
const blocked = join(workspace, 'race-blocked.txt')
script.push(
() => {
setSandboxMode(parent.session, 'read-only')
return textResponse('staged')
},
toolCallResponse('d-race', 'delegate', { fork: false }),
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('race child done'),
textResponse('turn two done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
await parent.whenIdle()
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate' }], source: { kind: 'user' } }))
await parent.whenIdle()
const child = captured[0] as Agent
// The child runs under the snapshot taken at delegation — the racing
// wider switch belongs to the parent's own future, not to the child.
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
})
it('a GRANDCHILD inherits through the chain (child delegates again)', async () => {
const script: Script = []
const captured: Agent[] = []
const { ctx, parent } = await setupWalled(script)
registerDelegate(ctx, captured)
const blocked = join(workspace, 'grandchild-blocked.txt')
script.push(
() => {
setSandboxMode(parent.session, 'read-only')
return textResponse('staged')
},
toolCallResponse('d-child', 'delegate', { fork: false }),
// Child immediately delegates the write to a grandchild.
toolCallResponse('d-grandchild', 'delegate', { fork: false }),
toolCallResponse('g-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('grandchild done'),
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
textResponse('parent done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
await parent.whenIdle()
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate twice' }], source: { kind: 'user' } }))
await parent.whenIdle()
expect(captured).toHaveLength(2)
const grandchild = captured[1] as Agent
expect(grandchild.session.header.delegationDepth).toBe(2)
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(grandchild).join('\n')).toContain(READ_ONLY_DENIAL)
expect(ctx.sandboxPolicy.resolve({ session: grandchild.session }).mode).toBe('read-only')
const run = await startInProcessRun(spawnRequest(parent), { seed })
try {
await run.result
const child = run.localAgent as Agent
expect(child.session.header.seedLength).toBe(1)
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 1, data: { mode: 'read-only', source: 'delegation' } },
])
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
setSandboxMode(child.session, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
} finally {
await run.dispose()
}
})
})
describe('inheritance survives prompt vetoes', () => {
it('keeps the baseline when an earlier-registered prompt-submit listener vetoes without next()', async () => {
const script: Script = []
it('captures policy at delegation before asynchronous child creation', async () => {
const script: Script = [textResponse('child done')]
const { ctx, parent } = await setupWalled(script)
// A veto-capable listener registered BEFORE the child exists — the
// Claude/Codex UserPromptSubmit hook shape: it blocks the child's prompt
// and never delegates. Inheritance must still run for the first turn.
ctx.on('agent/prompt-submit', (agent, _message, _signal, next) => {
if (agent.session.header.parentSession !== undefined) {
return Promise.resolve({ kind: 'block' as const, reason: 'vetoed by test hook' })
}
return next()
})
script.push(
() => {
setSandboxMode(parent.session, 'read-only')
return textResponse('staged')
},
// No child model entries: the blocked prompt closes a zero-step turn.
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
await parent.whenIdle()
setSandboxMode(parent.session, 'read-only')
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
const child = run.localAgent as Agent
// 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()
const starting = startInProcessRun(spawnRequest(parent), {})
setSandboxMode(parent.session, 'danger-full-access')
const run = await starting
try {
await run.result
const child = run.localAgent as Agent
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
} finally {
await run.dispose()
}
})
})
describe('inheritance guards (must hold before AND after the fix)', () => {
it('a child of an unswitched parent runs under the live deployment default, with NO baseline or events', async () => {
it('does not freeze deployment defaults into an unswitched child', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
script.push(
toolCallResponse('c-write', 'write', { file_path: allowed, content: 'fine' }),
toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
const child = run.localAgent as Agent
// workspace-write (the deployment default) really allowed the write…
expect(await readFile(allowed, 'utf8')).toBe('fine')
// …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()
})
it('delegation works unchanged when no sandbox/approval services are composed at all', async () => {
const script: Script = []
const { parent } = await setupBare(script)
script.push(textResponse('bare child answer'))
const run = await startInProcessRun(spawnRequest(parent), {})
const result = await run.result
const child = run.localAgent as Agent
expect(result.stopReason).toBe('completed')
expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 })
await run.dispose()
})
})
describe('what a blocked child experiences', () => {
it('an inherited "never" policy is stated in the child FIRST request system prompt', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
script.push(
() => {
setApprovalPolicy(parent.session, 'never')
return textResponse('staged')
},
textResponse('child done'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
const child = run.localAgent as Agent
// Model-visible ⟺ logged: the child was TOLD up front not to request
// escalation, in the very first request it ever saw.
const header = child.session.events.find((e): e is SessionEvent<'request/header'> => e.type === 'request/header')
expect(header?.data.header.system).toContain('Approval prompts are disabled')
await run.dispose()
})
it('a denied child that retries with sandbox_permissions fails closed on the REAL escalation gate', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const blocked = join(workspace, 'escalation-blocked.txt')
script.push(
() => {
setSandboxMode(parent.session, 'read-only')
return textResponse('staged')
},
// First attempt: denied by the fence.
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
// One-shot escalation retry, exactly as the denial hint teaches — the
// approval seam is mounted but NO answerer owns an in-process child.
toolCallResponse('c-escalate', 'write', {
file_path: blocked,
content: 'escaped',
sandbox_permissions: 'workspace-write',
justification: 'the test child wants to write inside the workspace',
}),
textResponse('child gave up'),
)
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } }))
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
const result = await run.result
const child = run.localAgent as Agent
// Nothing ever reached the disk — not the first attempt, not the retry.
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
const results = toolResultTexts(child)
expect(results[0]).toContain(READ_ONLY_DENIAL)
// The child's escalation resolves through the real approval waterfall to
// the distinct fail-closed reason — the honest "report upward" signal.
expect(results[1]).toContain('no approval channel is available')
expect(result.stopReason).toBe('completed')
await run.dispose()
try {
await run.result
const child = run.localAgent as Agent
expect(await readFile(allowed, 'utf8')).toBe('fine')
expect(child.session.events.some(
event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
)).toBe(false)
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
})