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