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:
kingwl
2026-07-25 04:06:19 +08:00
parent 690c53dc03
commit 669771097d
29 changed files with 2168 additions and 7 deletions

View File

@@ -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.

View File

@@ -19,7 +19,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode } 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

View File

@@ -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)
})
})