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:
@@ -121,7 +121,7 @@ Costs and accepted limits:
|
||||
- **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt.
|
||||
- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two.
|
||||
- **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant.
|
||||
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred).
|
||||
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. The session POLICY does cross the boundary: a `'never'` parent's children inherit `'never'` via a stamped override ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)), so they are told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred).
|
||||
- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair.
|
||||
- **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state.
|
||||
- **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own.
|
||||
|
||||
@@ -90,7 +90,7 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode —
|
||||
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
|
||||
```
|
||||
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's `workspace-write` cannot disturb another's `read-only`) both fall out by construction, and no external config store exists anywhere.
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's `workspace-write` cannot disturb another's `read-only`) both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver stamps a delegating parent's effective override onto each child session inside the child's first turn, so a tightened parent binds spawn children, fork children, and grandchildren ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
**One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages):
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-subagent-policy-inheritance.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session policy overrides are per-session log folds: the effective sandbox mode is `fold(session's sandbox/mode events) ?? deployment default` ([the sandbox Agent Note](2026-07-06-sandbox.md)), and the approval policy folds `approval/policy` the same way. In-process subagent children get a NEW session, so no override crossed the delegation boundary: a spawn child of a `read-only`-switched parent ran under the (possibly wider) deployment default — delegation was a bypass channel for a user's tightening — and a fork child inherited only whatever switch happened to sit inside its completed-turn seed, missing exactly the most common timing (the user switches while the agent is idle, so the switch lands after the last `turn/end` and outside the seed). A `'never'` (headless/CI) approval parent likewise minted children that fell back to a prompting default. The escalation hint a denied child sees ("the approval prompt asks the user") also promised a prompt no answerer would ever deliver.
|
||||
|
||||
## Decision
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
### What a blocked child experiences
|
||||
|
||||
A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by an editor — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A `sandboxMode`/`approvalPolicy` baseline in `SessionHeader` meta (the `delegationDepth` precedent)** — rejected: it survives the one corner the event approach loses (a child hard-killed before its first `turn/end` and then resumed loses the stamp), but that child has completed nothing and has no resume value, while the header field costs a session-format extension, a durable-boundary validation path, seed-slicing precedence logic in every fold consumer (`resolve()`, pty-local, permission display), and a second home for policy state. The event approach changes no fold, no format, and no consumer.
|
||||
- **Stamping at child creation (outside any turn)** — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright.
|
||||
- **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening.
|
||||
- **Forcing `approvalPolicy: 'never'` onto every in-process child** — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons.
|
||||
- **Routing a child's approval asks to the root session's editor** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-subagent-policy-inheritance.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话策略覆盖项是按会话的日志折叠:生效沙箱模式等于 `fold(session's sandbox/mode events) ?? deployment default`([沙箱 Agent Note](2026-07-06-sandbox.md)),审批策略以同样的方式折叠 `approval/policy`。进程内 subagent 的子 agent(智能体)拿到的是一个全新会话,因此没有任何覆盖项能跨过委派边界:父 agent 已切换到 `read-only` 时,其 spawn 子 agent 却运行在(可能更宽的)部署默认值之下,委派成了绕开用户收紧的旁路通道;fork 子 agent 只能继承恰好落在其已完成轮次种子内的切换,而恰恰漏掉最常见的时机(用户在 agent 空闲时切换,切换落在最后一个 `turn/end` 之后、种子之外)。审批策略为 `'never'`(无头/CI 场景)的父 agent,其创建出的子 agent 同样回退到了会向用户弹出提示的默认策略。被拒的子 agent 看到的升级提示文案(「审批提示会询问用户」)还承诺了一个永远不会有应答器送达的提示。
|
||||
|
||||
## 决策
|
||||
|
||||
共享的进程内驱动器(`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`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。
|
||||
- **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。
|
||||
- **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。
|
||||
|
||||
### 被拦住的子 agent 会经历什么
|
||||
|
||||
受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由编辑器持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **在 `SessionHeader` 的 meta 中放 `sandboxMode`/`approvalPolicy` 基线(沿用 `delegationDepth` 先例)**:不予采纳。它确实能扛住事件方案唯一丢失的边角场景(子 agent 在第一个 `turn/end` 前被强制杀死、随后又被恢复时会丢失盖章),但那样的子 agent 尚未完成任何工作,恢复毫无价值;而该会话头字段的代价是一次会话格式扩展、一条持久边界上的校验路径、每个折叠消费方(`resolve()`、pty-local、权限展示)里的种子切片优先级逻辑,以及策略状态的第二个存放处。事件方案不改动任何折叠、任何格式、任何消费方。
|
||||
- **在子 agent 创建时(任何轮次之外)盖章**:不予采纳。持久化契约在轮次边界提交,因此轮次开始前的裸事件在重新加载时会被当作撕裂尾部截断;会话不变量测试套件会直接判这种追加失败。
|
||||
- **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。
|
||||
- **给每个进程内子 agent 强制设置 `approvalPolicy: 'never'`**:不予采纳。这在今天是事实(没有应答器认领它们),但它会静默排除未来能够服务子 agent 的应答器,并搅浑继承语义;只继承父级的覆盖项既保住 fail-closed 结果,又让每次请求的拒绝原因保持诚实。
|
||||
- **把子 agent 的审批请求路由给根会话的编辑器**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。
|
||||
|
||||
## 后果
|
||||
|
||||
- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言),并由两个服务各自测试套件中的 `inheritOverride` 契约测试钉住。
|
||||
- 盖章写入的覆盖项是子 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` 的类型;两者在运行时仍然可选。
|
||||
Reference in New Issue
Block a user