fix(subagent): pin delegated child approvals to 'never' within the inherited sandbox scope

A delegated in-process child now acts only within the sandbox scope fixed
at delegation: captureDelegatedPolicyOverrides still snapshots the parent's
explicit sandbox override but pins the child approval policy to 'never'
(instead of inheriting the parent's), so every child ask — sandbox_permissions
escalations included — is rejected deterministically by ApprovalService
before any answerer, with the audit pair still logged. Every in-process
child additionally receives the scoped subagent:delegation runtime-context
statement telling it to report a scope limitation instead of retrying.

Supersedes the approval half of the policy-inheritance decision (new Agent
Note cross-linked from both prior notes and the approval-seam Q&A); refreshed
child snapshot fixtures carry the pinned event, and
subagent-published-run-failure now persists a one-event child log.
This commit is contained in:
Hypatia May
2026-08-10 19:17:37 +08:00
parent 1ac58714d1
commit 501c3a8ab6
61 changed files with 781 additions and 550 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: 4189979806ccd4e7dcfee231ab3e9e2331550b0d
README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32
README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14

View File

@@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p
This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cancellation and ownership

View File

@@ -20,7 +20,7 @@
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 取消与所有权

View File

@@ -1,4 +1,7 @@
/** Policy inheritance through child session events appended before publication. */
/**
* Delegation policy through child session events appended before publication:
* the parent's sandbox override plus the pinned `approval/policy: never`.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
@@ -13,7 +16,7 @@ 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 ApprovalService from '@deepseek-ai/dsh-user-approval'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -76,12 +79,14 @@ function toolResultTexts(agent: Agent): string[] {
}
describe('in-process policy inheritance', () => {
it('records parent overrides before publishing a spawn child', async () => {
it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
setSandboxMode(parent.session, 'read-only')
setApprovalPolicy(parent.session, 'never')
// The parent keeps the interactive deployment default: the child pin must
// not depend on any parent approval override.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const parentLogLength = parent.session.events.length
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
@@ -120,7 +125,11 @@ describe('in-process policy inheritance', () => {
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
// The delegation-scope statement is a runtime-context fact, so the
// deployment system prompt stays uniform across parents and children.
expect(contextText).toContain('You are a delegated subagent')
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
expect(request.data.header.system).not.toContain('You are a delegated subagent')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
@@ -179,7 +188,7 @@ describe('in-process policy inheritance', () => {
}
})
it('does not freeze deployment defaults into an unswitched child', async () => {
it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
@@ -193,12 +202,58 @@ describe('in-process policy inheritance', () => {
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.events.some(event => event.type === 'sandbox/mode')).toBe(false)
expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ seq: 0, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
// A root answerer that would GRANT: the pinned 'never' must resolve
// before any answerer is consulted, so this never runs for the child.
let consulted = false
ctx.on('approval/request', () => {
consulted = true
return Promise.resolve('allowed-once' as const)
})
const blocked = join(workspace, 'escalation-blocked.txt')
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', {
file_path: blocked,
content: 'escaped',
sandbox_permissions: 'workspace-write',
justification: 'test escalation from a delegated child',
}),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(consulted).toBe(false)
expect(toolResultTexts(child).join('\n'))
.toContain('the user rejected escalating this operation to "workspace-write"')
// The deterministic rejection still leaves the full audit pair on the child log.
const asked = child.session.events.find(
(event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
)
const decided = child.session.events.find(
(event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
)
expect(asked?.data.toolName).toBe('write')
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
} finally {
await run.dispose()
}
})
})

View File

@@ -247,10 +247,11 @@ describe('in-process structured output', () => {
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// Exactly one model request and one user message: no nudge turn exists.
// Exactly one model request and one caller-supplied user message (the
// delegation runtime-context snapshot aside): no nudge turn exists.
expect(adapter.requests.length).toBe(1)
const child = ctx.agents.get(run.id)!
expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
await run.dispose()
})