From 6fa2377e34a00ffa8a4c3c7ac19deff25cae48a1 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 10:51:25 +0800 Subject: [PATCH] subagent: capture overrides at delegation; stamp ahead of prompt vetoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 6 +- ...26-07-25-subagent-policy-inheritance.zh.md | 6 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 39 +++++---- .../sandbox-policy/tests/policy.spec.ts | 37 ++++----- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 42 ++++++---- .../tests/inheritance.spec.ts | 82 ++++++++++++++++++- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 30 ++++--- .../ui/user-approval/tests/approval.spec.ts | 31 +++---- 12 files changed, 192 insertions(+), 91 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index ab8cf722de..14cc3d747c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -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 -2026-07-25-subagent-policy-inheritance.md: bc5eb5b17ce34872db50b4cf848f6a1784ede0fc -2026-07-25-subagent-policy-inheritance.zh.md: 3498b1b01987c44156b922fe0dcdd6223495fed6 +2026-07-25-subagent-policy-inheritance.md: 42196f9b23b9bf31a1f441e9db900493f08e8094 +2026-07-25-subagent-policy-inheritance.zh.md: 3ad2bd3dde913850c0b7726a8b650d4f7f75ceda diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index bc5eb5b17c..42196f9b23 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -12,8 +12,8 @@ Session policy overrides are per-session log folds: the effective sandbox mode i The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) snapshots the parent's policy overrides at delegation and stamps them onto the child as ordinary log events inside the child's FIRST turn: -- **Read at creation, write at first `agent/prompt-submit`.** The driver installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. -- **Only the override chain is copied, through the canonical write paths.** `SandboxPolicyService.inheritOverride(parent, child)` and `ApprovalService.inheritOverride(parent, child)` each fold the parent's FULL live log (not the fork seed), append via `setSandboxMode`/`setApprovalPolicy` only when the parent has an override the child does not already fold to, and never copy the deployment default — an unswitched parent stamps nothing, so a resumed child keeps following the LIVE default. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. +- **Capture synchronously at delegation, stamp at first `agent/prompt-submit`.** The driver reads `overrideOf(parent.session)` for both knobs 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, not the child — and installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window, PREPENDED so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the first turn without the stamp. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. +- **Only the override chain is copied, through the canonical write paths.** `overrideOf(session)` is the fold alone — never the deployment/configured default — so an unswitched parent stamps nothing and a resumed child keeps following the LIVE default; `stampOverride(child, value)` appends via `setSandboxMode`/`setApprovalPolicy` unless the child already folds to the value. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. - **Fork stale-seed precedence falls out of log order.** The stamped event lands after any switch the seed carried, so the existing last-event-wins fold resolves the child's mode with no new precedence machinery; an equal seed-carried override is deduplicated instead of re-stamped. - **Nesting composes by construction.** A grandchild's stamp folds its parent-the-child's log, which already contains the child's stamped (or self-switched) override — the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter any log, so they can never leak down the chain. @@ -31,7 +31,7 @@ A confined child that hits the wall gets the ordinary denial marker; an escalati ## Consequences -- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers) and the `inheritOverride` contract tests in the two service suites. +- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race and a veto-capable prompt-submit listener) and the `overrideOf`/`stampOverride` contract tests in the two service suites. - The stamped override is the child's own durable record: resume replays it like any switch, and the child may later be switched independently without the driver re-stamping over it (one-shot listener + fold dedup). - Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); a child hard-killed before its first `turn/end` loses the stamp on resume (worthless-resume corner, recorded above); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. - `dsh-subagent-inprocess` now declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 3498b1b019..3ad2bd3dde 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -12,8 +12,8 @@ Status: implemented 共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: -- **创建时读取,首个 `agent/prompt-submit` 时写入。**驱动器在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 -- **只复制覆盖链,且全部走规范写入路径。**`SandboxPolicyService.inheritOverride(parent, child)` 与 `ApprovalService.inheritOverride(parent, child)` 各自折叠父级的完整实时日志(而非 fork 种子),只在父级持有子 agent 尚未折叠出的覆盖项时才通过 `setSandboxMode`/`setApprovalPolicy` 追加,并且从不复制部署默认值:未切换过的父级不盖任何章,因此恢复后的子 agent 继续跟随实时默认值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 +- **只复制覆盖链,且全部走规范写入路径。**`overrideOf(session)` 只是折叠本身——从不包含部署/配置默认值——因此未切换过的父级不盖任何章,恢复后的子 agent 继续跟随实时默认值;`stampOverride(child, value)` 通过 `setSandboxMode`/`setApprovalPolicy` 追加,除非子 agent 已折叠出该值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 - **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 - **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 @@ -31,7 +31,7 @@ Status: implemented ## 后果 -- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言),并由两个服务各自测试套件中的 `inheritOverride` 契约测试钉住。 +- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例,以及一个具备否决能力的 prompt-submit 监听器用例),并由两个服务各自测试套件中的 `overrideOf`/`stampOverride` 契约测试钉住。 - 盖章写入的覆盖项是子 agent 自己的持久记录:恢复时它像任何一次切换一样被回放;子 agent 之后仍可被独立切换,驱动器不会重新盖章覆盖它(一次性监听器加折叠去重)。 - 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);子 agent 在第一个 `turn/end` 前被强制杀死后,恢复时会丢失盖章(恢复无价值的边角场景,上文已记录);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 - `dsh-subagent-inprocess` 现在将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 9226c35529..968ba0b504 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -17,7 +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)). +- `ctx.sandboxPolicy.overrideOf(session)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps 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. diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index ba2810bd53..e69a16efbe 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -106,21 +106,32 @@ export class SandboxPolicyService extends Service { } /** - * 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. + * A session's sandbox-mode OVERRIDE — the fold alone, never the deployment + * default. The read half of delegation inheritance: the subagent driver + * captures this synchronously at delegation, so a parent switch racing the + * child's asynchronous creation belongs to the parent's future, not to the + * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to fold. + * @returns the last switched mode, or `undefined` for a never-switched session. */ - inheritOverride(parent: Session, child: Session): void { - const inherited = effectiveSandboxMode(parent.events) - if (inherited === undefined || effectiveSandboxMode(child.events) === inherited) return - setSandboxMode(child, inherited) + overrideOf(session: Session): SandboxMode | undefined { + return effectiveSandboxMode(session.events) + } + + /** + * Stamp a captured override onto a child session through the canonical + * write path — the write half of delegation inheritance: a child agent runs + * under the policy its delegating parent was switched to, not under the + * (possibly wider) deployment default. A child whose log (e.g. a fork seed) + * already folds to the mode is left untouched. Callers must append inside + * an open child turn — a bare between-turn event is crash-tail garbage on + * reload. + * @param child - the child session the override is appended to. + * @param mode - the captured {@link overrideOf} value to stamp. + */ + stampOverride(child: Session, mode: SandboxMode): void { + if (effectiveSandboxMode(child.events) === mode) return + setSandboxMode(child, mode) } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 7ced415377..808b5c089b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -143,45 +143,40 @@ describe('the sandbox/mode session kit', () => { }) }) -describe('inheritOverride (parent → child stamping)', () => { +describe('delegation inheritance (overrideOf + stampOverride)', () => { 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() + it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) 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) + expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only') + // undefined, NOT the deployment default — a child stamped with the + // default would stop following the LIVE default across resumes. + expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined() + }) + + it('stampOverride appends the captured mode through the canonical write path', async () => { + const ctx = await mounted() + const child = session('sess-inherit-child') + + ctx.sandboxPolicy.stampOverride(child, 'read-only') 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 () => { + it('stampOverride skips a child already folding to the 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) + ctx.sandboxPolicy.stampOverride(child, 'read-only') expect(modeEvents(child)).toHaveLength(1) }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index b41e1acdfa..d3f8d096de 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -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 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index b81725ce80..b88eef2548 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -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 } diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 6ddcc4f4fb..6e5e78fe9b 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -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 = [] diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 0ac341b6b4..27a4a1e622 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -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. `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)). +`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.overrideOf(session)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps 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). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index be059577e5..06f94a5ddb 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -327,21 +327,31 @@ export class ApprovalService extends Service { } /** - * Stamp the parent's approval-policy OVERRIDE onto a child session through - * the canonical write path — the delegation-inheritance step: a `'never'` + * A session's approval-policy OVERRIDE — the fold alone, never the + * configured default. The read half of delegation inheritance: the subagent + * driver captures this synchronously at delegation, so a parent switch + * racing the child's asynchronous creation belongs to the parent's future, + * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to fold. + * @returns the last switched policy, or `undefined` for a never-switched session. + */ + overrideOf(session: Session): ApprovalPolicy | undefined { + return effectiveApprovalPolicy(session.events) + } + + /** + * Stamp a captured override onto a child session through the canonical + * write path — the write half of delegation inheritance: 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 + * default. A child whose log (e.g. a fork seed) already folds to the 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. + * @param policy - the captured {@link overrideOf} value to stamp. */ - inheritOverride(parent: Session, child: Session): void { - const inherited = effectiveApprovalPolicy(parent.events) - if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return - setApprovalPolicy(child, inherited) + stampOverride(child: Session, policy: ApprovalPolicy): void { + if (effectiveApprovalPolicy(child.events) === policy) return + setApprovalPolicy(child, policy) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 83661a5d2b..9b1664c27c 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -577,44 +577,39 @@ describe('approval policy (the approval/policy fold)', () => { }) }) -describe('inheritOverride (parent → child stamping)', () => { +describe('delegation inheritance (overrideOf + stampOverride)', () => { 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 () => { + it('overrideOf folds to the LAST override and never falls back to the configured default', 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) + expect(ctx.approval.overrideOf(parent)).toBe('never') + expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() + }) + + it('stampOverride appends the captured policy through the canonical write path', async () => { + const ctx = await mounted() + const child = bareSession('sess-appr-inherit-child') + + ctx.approval.stampOverride(child, 'never') 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 () => { + it('stampOverride skips a child already folding to the policy (fork-seed dedup)', 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) + ctx.approval.stampOverride(child, 'never') expect(policyEvents(child)).toHaveLength(1) })