subagent: capture overrides at delegation; stamp ahead of prompt vetoes

Review fixes (ds-review-bot on #623):

- Capture-at-delegation: the driver now reads overrideOf(parent.session)
  for both knobs synchronously before its first await, and the prompt-submit
  listener stamps those captured values — a parent switch racing the child's
  asynchronous creation belongs to the parent's future, not the child. The
  inheritOverride(parent, child) service method is split into its two halves
  (overrideOf / stampOverride) accordingly.
- Veto safety: the one-shot prompt-submit listener registers with
  prepend: true, so a veto-capable listener (a denying UserPromptSubmit
  hook) cannot close the child's first turn without the durable stamp.

Both regressions are pinned red-first in inheritance.spec.ts: the
delegation-vs-late-switch race (delegate tool flips the caller wider while
the creation transaction is pending) and a blocking prompt-submit listener
(stamp survives a promptless first turn). Service contract tests renamed to
the split API; READMEs and the bilingual Agent Note updated.
This commit is contained in:
kingwl
2026-07-25 10:51:25 +08:00
parent 669771097d
commit 6fa2377e34
12 changed files with 192 additions and 91 deletions

View File

@@ -16,7 +16,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: a one-shot `agent/prompt-submit` listener installed during setup stamps the parent's effective `sandbox/mode` and `approval/policy` overrides onto the child through `ctx.sandboxPolicy.inheritOverride` / `ctx.approval.inheritOverride` (both 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, and 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 stamp 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 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)).
## Cancellation and ownership

View File

@@ -100,6 +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
// 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)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
if (request.persona !== undefined) {
@@ -109,20 +120,23 @@ export async function startInProcessRun(
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
// Policy inheritance: stamp the parent's sandbox/approval OVERRIDES onto
// the child 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. One-shot: later turns must
// not re-stamp over a switch the child made itself. Both services are
// consumed opportunistically — without them, delegation stays policy-free.
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
disposeInherit()
parent.ctx.get('sandboxPolicy')?.inheritOverride(parent.session, childAgent.session)
parent.ctx.get('approval')?.inheritOverride(parent.session, childAgent.session)
return next()
})
// 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 }

View File

@@ -102,9 +102,11 @@ async function setupBare(script: Script) {
* "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.
* 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[]): void {
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)',
@@ -115,10 +117,15 @@ function registerDelegate(ctx: Context, captured: Agent[]): void {
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 run = await startInProcessRun(
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()
@@ -264,6 +271,38 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
expect(overrideEvents(child).sandbox).toBe(1)
})
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.send([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'delegate' }])
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[] = []
@@ -297,6 +336,43 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
})
})
describe('inheritance survives prompt vetoes', () => {
it('stamps the child even when an earlier-registered prompt-submit listener vetoes without next()', async () => {
const script: Script = []
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, _content, _source, _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.send([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
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 })
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
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 ZERO stamped events', async () => {
const script: Script = []