subagent: inherit parent sandbox/approval overrides in in-process children
Per-session policy overrides (sandbox/mode, approval/policy) never crossed the delegation boundary: a spawn child of a read-only-switched parent ran under the wider deployment default, and a fork child missed any switch made after its seed boundary — delegation was a bypass channel for a user's tightening. The in-process driver now snapshots the delegating parent's override chain and stamps it onto the child through the canonical write paths (SandboxPolicyService.inheritOverride / ApprovalService.inheritOverride), anchored inside the child's first turn via a one-shot agent/prompt-submit listener: turn-enclosed (durable), ahead of the first request (an inherited 'never' reaches the child's first system prompt), and positioned after any stale fork-seed switch so the ordinary last-event-wins fold resolves it. Only overrides are copied — an unswitched parent stamps nothing and the child follows the live deployment default; both services are consumed opportunistically, so compositions without them delegate unchanged. Nesting composes by construction (each stamp folds the already-stamped parent log). Evidence: inheritance.spec.ts drives scripted-model children into the real dsh-fs-sandbox fence through the real write tool (disk-state + denial-marker assertions; spawn, stale-seed fork, grandchild, escalation fail-closed, and no-stamp guards), inheritOverride contract tests in both service suites, and the recorded subagent-sandbox-inheritance ACP snapshot (read-only preset → delegate → child denied, replayed keylessly). See .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md.
This commit is contained in:
@@ -150,6 +150,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async request(req: ApprovalRequest): Promise<ApprovalOutcome>',
|
||||
jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'inheritOverride(parent: Session, child: Session): void',
|
||||
jsDoc: '/**\n * Stamp the parent\'s approval-policy OVERRIDE onto a child session through\n * the canonical write path — the delegation-inheritance step: a `\'never\'`\n * (headless/CI) parent must not mint children that fall back to a prompting\n * default. Only the override chain is copied: an unswitched parent stamps\n * nothing, so the child keeps following the LIVE configured default. A\n * child whose log (e.g. a fork seed) already folds to the inherited policy\n * is left untouched. Callers must append inside an open child turn — a bare\n * between-turn event is crash-tail garbage on reload.\n * @param parent - the delegating session whose effective override is read.\n * @param child - the child session the override is appended to.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -442,6 +446,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy',
|
||||
jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'inheritOverride(parent: Session, child: Session): void',
|
||||
jsDoc: '/**\n * Stamp the parent\'s sandbox-mode OVERRIDE onto a child session through the\n * canonical write path — the delegation-inheritance step: a child agent runs\n * under the policy its delegating parent was switched to, not under the\n * (possibly wider) deployment default. Only the override chain is copied: an\n * unswitched parent stamps nothing, so the child keeps following the LIVE\n * deployment default. A child whose log (e.g. a fork seed) already folds to\n * the inherited mode is left untouched. Callers must append inside an open\n * child turn — a bare between-turn event is crash-tail garbage on reload.\n * @param parent - the delegating session whose effective override is read.\n * @param child - the child session the override is appended to.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,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.inheritOverride(parent, child)` — the delegation-inheritance step: stamps the parent session's effective override (never the deployment default) onto a child session through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver calls it 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)).
|
||||
- `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.
|
||||
|
||||
@@ -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 } from './session-mode.ts'
|
||||
import { effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
|
||||
@@ -104,6 +104,24 @@ export class SandboxPolicyService extends Service {
|
||||
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the parent's sandbox-mode OVERRIDE onto a child session through the
|
||||
* canonical write path — the delegation-inheritance step: a child agent runs
|
||||
* under the policy its delegating parent was switched to, not under the
|
||||
* (possibly wider) deployment default. Only the override chain is copied: an
|
||||
* unswitched parent stamps nothing, so the child keeps following the LIVE
|
||||
* deployment default. A child whose log (e.g. a fork seed) already folds to
|
||||
* the inherited mode is left untouched. Callers must append inside an open
|
||||
* child turn — a bare between-turn event is crash-tail garbage on reload.
|
||||
* @param parent - the delegating session whose effective override is read.
|
||||
* @param child - the child session the override is appended to.
|
||||
*/
|
||||
inheritOverride(parent: Session, child: Session): void {
|
||||
const inherited = effectiveSandboxMode(parent.events)
|
||||
if (inherited === undefined || effectiveSandboxMode(child.events) === inherited) return
|
||||
setSandboxMode(child, inherited)
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxPolicyService
|
||||
|
||||
@@ -142,3 +142,47 @@ describe('the sandbox/mode session kit', () => {
|
||||
expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritOverride (parent → child stamping)', () => {
|
||||
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
|
||||
|
||||
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = session('sess-inherit-parent')
|
||||
const child = session('sess-inherit-child')
|
||||
setSandboxMode(parent, 'workspace-write')
|
||||
setSandboxMode(parent, 'read-only')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
|
||||
const stamped = modeEvents(child)
|
||||
expect(stamped).toHaveLength(1)
|
||||
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
|
||||
})
|
||||
|
||||
it('appends NOTHING when the parent never switched (the deployment default must stay live)', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
const parent = session('sess-inherit-default-parent')
|
||||
const child = session('sess-inherit-default-child')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
|
||||
// No event — a resumed child keeps following whatever the deployment
|
||||
// default is THEN, instead of a frozen copy of today's default.
|
||||
expect(child.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips the append when the child already folds to the inherited mode (fork-seed dedup)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = session('sess-inherit-dedup-parent')
|
||||
const child = session('sess-inherit-dedup-child')
|
||||
setSandboxMode(parent, 'read-only')
|
||||
// A fork seed can already carry the parent's switch; stamping again would
|
||||
// append a redundant event on every delegation.
|
||||
setSandboxMode(child, 'read-only')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
|
||||
expect(modeEvents(child)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ 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)).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -30,22 +30,28 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReaso
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
// to the policy services when composed — the driver consumes both
|
||||
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
attachStructuredRuntime,
|
||||
type StructuredAttachment,
|
||||
@@ -104,6 +109,20 @@ 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()
|
||||
})
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
|
||||
403
packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
Normal file
403
packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 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 stamping design is itself pinned by the mounted session
|
||||
* invariants: an implementation that appends the inherited override OUTSIDE
|
||||
* the child's first turn fails these suites through the turn-enclosure check.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } 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 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 * 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'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
|
||||
|
||||
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 () => {
|
||||
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) {
|
||||
const ctx = new Context()
|
||||
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 })
|
||||
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.
|
||||
*/
|
||||
function registerDelegate(ctx: Context, captured: Agent[]): 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' } },
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
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 run = await startInProcessRun(
|
||||
{ prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal },
|
||||
args.fork === true && seed.length > 0 ? { seed } : {},
|
||||
)
|
||||
captured.push(run.localAgent as Agent)
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
return [{ type: 'text', text: `child:${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.content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.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' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
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.send([{ type: 'text', text: 'stage the session policy' }])
|
||||
await parent.whenIdle()
|
||||
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 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)
|
||||
// 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('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => {
|
||||
const script: Script = []
|
||||
const captured: Agent[] = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
registerDelegate(ctx, captured)
|
||||
const blocked = join(workspace, 'fork-blocked.txt')
|
||||
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.send([{ type: 'text', text: 'turn one' }])
|
||||
await parent.whenIdle()
|
||||
parent.send([{ type: 'text', text: 'turn two: delegate' }])
|
||||
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('a FORK child whose seed already folds to the parent mode gets NO duplicate stamp (guard)', async () => {
|
||||
const script: Script = []
|
||||
const captured: Agent[] = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
registerDelegate(ctx, captured)
|
||||
const blocked = join(workspace, 'fork-dedup-blocked.txt')
|
||||
script.push(
|
||||
() => {
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
return textResponse('turn one')
|
||||
},
|
||||
toolCallResponse('d-fork', 'delegate', { fork: true }),
|
||||
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
|
||||
textResponse('fork child done'),
|
||||
textResponse('turn two done'),
|
||||
)
|
||||
parent.send([{ type: 'text', text: 'turn one' }])
|
||||
await parent.whenIdle()
|
||||
parent.send([{ type: 'text', text: 'turn two: delegate' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const child = captured[0] as Agent
|
||||
// The seed-carried override keeps enforcing…
|
||||
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
|
||||
// …and inheritance did not append a redundant copy on top of it.
|
||||
expect(overrideEvents(child).sandbox).toBe(1)
|
||||
})
|
||||
|
||||
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'),
|
||||
textResponse('child done'),
|
||||
textResponse('parent done'),
|
||||
)
|
||||
parent.send([{ type: 'text', text: 'stage' }])
|
||||
await parent.whenIdle()
|
||||
parent.send([{ type: 'text', text: 'delegate twice' }])
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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 = []
|
||||
const { parent } = await setupWalled(script)
|
||||
const allowed = join(workspace, 'default-allowed.txt')
|
||||
script.push(
|
||||
toolCallResponse('c-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 log.
|
||||
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.send([{ type: 'text', text: 'stage' }])
|
||||
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.send([{ type: 'text', text: 'stage' }])
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -32,8 +32,14 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap
|
||||
|
||||
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
|
||||
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.inheritOverride(parent, child)` stamps a parent session's override (never the configured default) onto a child session through that write path — the in-process subagent driver calls it inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
|
||||
@@ -326,6 +326,24 @@ export class ApprovalService extends Service {
|
||||
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the parent's approval-policy OVERRIDE onto a child session through
|
||||
* the canonical write path — the delegation-inheritance step: a `'never'`
|
||||
* (headless/CI) parent must not mint children that fall back to a prompting
|
||||
* default. Only the override chain is copied: an unswitched parent stamps
|
||||
* nothing, so the child keeps following the LIVE configured default. A
|
||||
* child whose log (e.g. a fork seed) already folds to the inherited policy
|
||||
* is left untouched. Callers must append inside an open child turn — a bare
|
||||
* between-turn event is crash-tail garbage on reload.
|
||||
* @param parent - the delegating session whose effective override is read.
|
||||
* @param child - the child session the override is appended to.
|
||||
*/
|
||||
inheritOverride(parent: Session, child: Session): void {
|
||||
const inherited = effectiveApprovalPolicy(parent.events)
|
||||
if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return
|
||||
setApprovalPolicy(child, inherited)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the waterfall, contained and raced against the request signal.
|
||||
* @param req - the borrowed public request.
|
||||
|
||||
@@ -576,3 +576,46 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
expect(afterDispose.injected).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritOverride (parent → child stamping)', () => {
|
||||
const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy')
|
||||
|
||||
function bareSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
|
||||
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-inherit-parent')
|
||||
const child = bareSession('sess-appr-inherit-child')
|
||||
setApprovalPolicy(parent, 'never')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
|
||||
const stamped = policyEvents(child)
|
||||
expect(stamped).toHaveLength(1)
|
||||
expect(stamped[0]?.data).toEqual({ policy: 'never' })
|
||||
})
|
||||
|
||||
it('appends NOTHING when the parent never switched (the configured default must stay live)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-default-parent')
|
||||
const child = bareSession('sess-appr-default-child')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
|
||||
expect(child.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips the append when the child already folds to the inherited policy (fork-seed dedup)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-dedup-parent')
|
||||
const child = bareSession('sess-appr-dedup-child')
|
||||
setApprovalPolicy(parent, 'never')
|
||||
setApprovalPolicy(child, 'never')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
|
||||
expect(policyEvents(child)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user