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 163bbe57c4..1de32450b5 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: 000e59dc9a9208e57db5dd44d34bd6ce540801da -2026-07-25-subagent-policy-inheritance.zh.md: 90738b4c298eea879a0093bf919139c8c70f00ef +2026-07-25-subagent-policy-inheritance.md: a15a20aeb8324f00fcb9ca85aac5d5e2352dfac3 +2026-07-25-subagent-policy-inheritance.zh.md: 2ac844fdd9bbb1b4a57e76a96a5cd5270c23d2aa 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 000e59dc9a..a15a20aeb8 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 @@ -10,28 +10,28 @@ Session policy overrides are per-session log folds: the effective sandbox mode i ## 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: +The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the `delegationDepth` precedent: -- **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). An injection-triggered first turn (a SessionStart hook racing the prompt) cannot starve the listener: injection turns dispatch no prompt-submit and no model request, so the stamp still lands before the child's first model request. -- **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. +- **Capture synchronously at delegation, persist in the creation-time header.** 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 stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. +- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — on `SandboxPolicyService` and `ApprovalService` — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary on every read (durable-boundary validation) and throws on foreign values. 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 the seed boundary.** A fork seed may carry the parent's OLD switch events; `overrideOf` folds only events past `header.seedLength`, so seed-carried history is subsumed by the delegation-time baseline while a switch the child makes ITSELF still outranks it. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. +- **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, 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. +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 a controller that can answer — 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 the inherited override as `sandbox/mode`/`approval/policy` events inside the child's first turn (the shipped first iteration)** — superseded: it kept the log-as-store idiom with zero format changes, but review surfaced a durability hole the turn-enclosure contract cannot patch — an idle SessionStart-style injection persists a complete one-shot turn BEFORE any prompt turn opens, so a crash in that window leaves a resumable-looking child with no inherited policy, and no event anchor exists earlier (creation-time appends are crash-tail garbage, injection turns dispatch no waterfall, `session/event` listeners cannot re-append). The header baseline closes every timing window at once and deletes the listener/prepend/dedup machinery the event approach needed. - **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. +- **Routing a child's approval asks to the root session's controller** — 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 — 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. +- 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, with no first-turn timing window (veto, injection, crash). 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, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). +- The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. +- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); 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` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). 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 90738b4c29..2ac844fdd9 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 @@ -10,28 +10,28 @@ Status: implemented ## 决策 -共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: +共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: -- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。由注入触发的第一个轮次(SessionStart 钩子与提示词赛跑)不会饿死该监听器:注入轮次既不派发 prompt-submit 也不发起模型请求,因此盖章仍会落在子 agent 的第一次模型请求之前。 -- **只复制覆盖链,且全部走规范写入路径。**`overrideOf(session)` 只是折叠本身——从不包含部署/配置默认值——因此未切换过的父级不盖任何章,恢复后的子 agent 继续跟随实时默认值;`stampOverride(child, value)` 通过 `setSandboxMode`/`setApprovalPolicy` 追加,除非子 agent 已折叠出该值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 -- **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 +- **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 +- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——位于 `SandboxPolicyService` 与 `ApprovalService` 上——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都按自己的封闭词汇校验(持久边界校验),遇到词汇之外的值即抛出异常。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **fork 陈旧种子的优先级由种子边界自然得出。**fork 种子可能携带父级旧的切换事件;`overrideOf` 只折叠 `header.seedLength` 之后的事件,因此种子携带的历史已被委派时的基线所涵盖,而子 agent 自己做出的切换仍然优先于基线。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 +- **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 ### 被拦住的子 agent 会经历什么 -受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由编辑器持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 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 的第一个轮次内,把继承的覆盖项作为 `sandbox/mode`/`approval/policy` 事件盖章写入(已合入的第一版实现)**:已被取代。它保住了「日志即存储」的惯用法,零格式变更,但评审发现了一个轮次封闭契约无法修补的持久性漏洞:空闲时的 SessionStart 式注入会在任何提示词轮次开启之前就持久化一个完整的一次性轮次,在该窗口内崩溃会留下一个看似可恢复、却没有任何继承策略的子 agent,而且不存在更早的事件锚点(创建时的追加只是崩溃残留的尾部垃圾,注入轮次不派发任何 waterfall,`session/event` 监听器也无法重入追加)。会话头基线一举关闭所有时序窗口,并删除了事件方案所需的监听器/前置安装/去重机制。 - **在子 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`。在此记录,以免这些障碍被再次推导。 +- **把子 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` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例,以及一个具备否决能力的 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` 的类型;两者在运行时仍然可选。 +- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 +- 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 +- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 +- `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 259c79ead2..2a137c2f70 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:225`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:227`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -244,27 +244,21 @@ Approval service that applies session policy before answerers and logs every ask async request(req: ApprovalRequest): Promise /** - * 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. + * A session's approval-policy OVERRIDE — the override chain alone, never + * the configured default: the fold of the session's OWN switches (events + * past the seed boundary — a fork seed's stale parent switch is subsumed by + * the baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so a `'never'` (headless/CI) parent cannot + * mint children that fall back to a prompting default, at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the configured default. + * @throws when the durable header baseline is outside the closed policy + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined - -/** - * 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. 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 child - the child session the override is appended to. - * @param policy - the captured {@link overrideOf} value to stamp. - */ -stampOverride(child: Session, policy: ApprovalPolicy): void ``` Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) @@ -974,28 +968,21 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy /** - * 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. + * A session's sandbox-mode OVERRIDE — the override chain alone, never the + * deployment default: the fold of the session's OWN switches (events past + * the seed boundary — a fork seed's stale parent switch is subsumed by the + * baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so the chain collapses one level per + * delegation and a tightened parent binds children at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the deployment default. + * @throws when the durable header baseline is outside the closed mode + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined - -/** - * 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 ``` Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) @@ -1389,7 +1376,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ebb8bfb6f3..3a0dd632f7 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 -persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e -persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd +persistence.md: 0c8d067fc28ee9075354bf32e511e72d66ef530d +persistence.zh.md: fcb07486a401283ff52e64a4d8ed2f25e2218187 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4ec967873e..0c8d067fc2 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -72,12 +72,30 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, the inherited `sandboxMode`/`approvalPolicy` delegation baselines, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -98,6 +116,8 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3030ff2fe9..fcb07486a4 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -72,12 +72,30 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`、继承的 `sandboxMode`/`approvalPolicy` 委派基线,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -98,6 +116,8 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8b8a73986e..b901425b8b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -17,7 +17,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cfa787d4e3..a97b28948d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:418`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `compact/*` @@ -329,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `request/*` @@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -399,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -503,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) ### `turn/*` @@ -521,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -537,7 +537,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) ### `user/*` @@ -556,4 +556,4 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 01bcd83249..bacb7b4842 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -61,7 +61,7 @@ describe('ACP machine permission policy', () => { harness = await makeBridgeHarness() const request = await ownedRequest() const foreign = { - session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], header: { version: 0, id: request.agent.session.id, createdAt: 0 }, append: () => ({}) }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1a8e21a957..a586702cc7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,11 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the fold alone, never the\n * configured default. The read half of delegation inheritance: the subagent\n * driver captures this synchronously at delegation, so a parent switch\n * racing the child\'s asynchronous creation belongs to the parent\'s future,\n * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched policy, or `undefined` for a never-switched session.\n */', - }, - { - signature: 'stampOverride(child: Session, policy: ApprovalPolicy): void', - jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a `\'never\'`\n * (headless/CI) parent must not mint children that fall back to a prompting\n * default. A child whose log (e.g. a fork seed) already folds to the policy\n * is left untouched. Callers must append inside an open child turn — a bare\n * between-turn event is crash-tail garbage on reload.\n * @param child - the child session the override is appended to.\n * @param policy - the captured {@link overrideOf} value to stamp.\n */', + jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the override chain alone, never\n * the configured default: the fold of the session\'s OWN switches (events\n * past the seed boundary — a fork seed\'s stale parent switch is subsumed by\n * the baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so a `\'never\'` (headless/CI) parent cannot\n * mint children that fall back to a prompting default, at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n * @throws when the durable header baseline is outside the closed policy\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', }, ], }, @@ -496,11 +492,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', - jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the fold alone, never the deployment\n * default. The read half of delegation inheritance: the subagent driver\n * captures this synchronously at delegation, so a parent switch racing the\n * child\'s asynchronous creation belongs to the parent\'s future, not to the\n * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched mode, or `undefined` for a never-switched session.\n */', - }, - { - signature: 'stampOverride(child: Session, mode: SandboxMode): void', - jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a child agent runs\n * under the policy its delegating parent was switched to, not under the\n * (possibly wider) deployment default. A child whose log (e.g. a fork seed)\n * already folds to the mode is left untouched. Callers must append inside\n * an open child turn — a bare between-turn event is crash-tail garbage on\n * reload.\n * @param child - the child session the override is appended to.\n * @param mode - the captured {@link overrideOf} value to stamp.\n */', + jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the override chain alone, never the\n * deployment default: the fold of the session\'s OWN switches (events past\n * the seed boundary — a fork seed\'s stale parent switch is subsumed by the\n * baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so the chain collapses one level per\n * delegation and a tightened parent binds children at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n * @throws when the durable header baseline is outside the closed mode\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', }, ], }, @@ -1514,7 +1506,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateGoalRequest', @@ -1522,7 +1514,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n}', }, { name: 'DiffCallView', @@ -2002,7 +1994,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b939bd6e34..fe80dcec08 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -48,9 +48,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` - * recursion budget. Mirrors the - * `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of + * fork lineage, the `seedLength` seed boundary, the `delegationDepth` + * recursion budget, and the inherited `sandboxMode`/`approvalPolicy` + * delegation baselines. Mirrors the corresponding fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, @@ -62,6 +62,8 @@ export interface CreateAgentOptions { readonly parentSession?: SessionId readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } /** * Seed events to reconstruct the child session's log from (the fork lineage diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 2ce7add3c2..53358283c8 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.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 -README.md: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c -README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb +README.md: d1f13669c224ea9fe70a08e6ecacd15118bfb632 +README.zh.md: ef9dd562a2c849f933ed8dafbfa65c92a3e6bcad diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 18d6d385ff..d1f13669c2 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome. @@ -44,7 +44,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 93ea574eb0..ef9dd562a2 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,7 +12,7 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。 @@ -44,7 +44,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -87,7 +87,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..82354bf825 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -151,6 +151,14 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } + // Neutral strings only: the owning policy packages validate the values + // against their closed vocabularies on read (durable boundary). + if (record.sandboxMode !== undefined && typeof record.sandboxMode !== 'string') { + throw new Error('session header sandboxMode must be a string') + } + if (record.approvalPolicy !== undefined && typeof record.approvalPolicy !== 'string') { + throw new Error('session header approvalPolicy must be a string') + } return deepFreeze(record as unknown as SessionHeader) } @@ -680,6 +688,8 @@ export class SessionStore extends Service { ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, + ...meta?.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, + ...meta?.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, } return new Session(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index a01f1a6a79..f5e5d48c77 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -53,6 +53,24 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } /** @@ -73,6 +91,8 @@ export interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 50c8654b9e..ce481bea2d 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -661,7 +661,7 @@ describe('ToolRegistry', () => { */ function fakeAgent(): Agent { return { - session: { events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) }, } as unknown as Agent } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index b466ecfc46..85d3938eb8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -9,10 +9,12 @@ import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +// Type-only: the `ctx.sandboxPolicy` Context merge and the `sandbox/mode` +// SessionEventMap merge; the service itself arrives via `inject`. +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' @@ -47,7 +49,7 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode + const currentMode = state.sandboxPolicy.overrideOf(session) ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, @@ -76,7 +78,7 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] { const argv = [config.shellPath, ...config.shellArgs] - const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode + const mode: SandboxMode = ctx.sandboxPolicy.overrideOf(spec.owner.session) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv return ctx.sandbox.confine(argv, { mode: mode, diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 67117d4284..778b213f3d 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.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 -README.md: 3e6d8082766d718b0cfcb94369f48a283713fcd2 -README.zh.md: 2f84bd4971ddbe0f15e78ef7f5f069350ff43f1f +README.md: 733a14141a8a67728b026c7a19d18266b61cbf6a +README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 3e6d808276..733a14141a 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -19,7 +19,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.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)). +- `ctx.sandboxPolicy.overrideOf(session)` — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([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/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2f84bd4971..916baf00b6 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -19,7 +19,7 @@ - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 - `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `ctx.sandboxPolicy.overrideOf(session)`/`ctx.sandboxPolicy.stampOverride(child, mode)`:委派继承的两半:仅折叠本身(绝不包含部署默认值),以及通过 `setSandboxMode` 写入捕获的覆盖项,子 agent 已折叠出该值时跳过。进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使发起委派的父级收紧后的模式约束其子 agent(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index e69a16efbe..46124f2373 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -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, setSandboxMode } from './session-mode.ts' +import { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' @@ -100,38 +100,35 @@ export class SandboxPolicyService extends Service { resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request return { - mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode, + mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } /** - * 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. + * A session's sandbox-mode OVERRIDE — the override chain alone, never the + * deployment default: the fold of the session's OWN switches (events past + * the seed boundary — a fork seed's stale parent switch is subsumed by the + * baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so the chain collapses one level per + * delegation and a tightened parent binds children at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the deployment default. + * @throws when the durable header baseline is outside the closed mode + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ 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) + const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) + if (own !== undefined) return own + const baseline = session.header.sandboxMode + if (baseline === undefined) return undefined + if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { + throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) + } + return baseline as SandboxMode } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index e5bfb59778..590497563b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -143,41 +143,56 @@ describe('the sandbox/mode session kit', () => { }) }) -describe('delegation inheritance (overrideOf + stampOverride)', () => { - const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode') +describe('delegation inheritance (overrideOf over the header baseline)', () => { + /** A session whose header carries the delegation-inheritance baseline. */ + function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, + ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + }) + } - it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => { + it('overrideOf folds the session log and never falls back to the deployment default', async () => { const ctx = await mounted({ mode: 'workspace-write' }) const parent = session('sess-inherit-parent') setSandboxMode(parent, 'workspace-write') setSandboxMode(parent, 'read-only') expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only') - // undefined, NOT the deployment default — a child stamped with the + // undefined, NOT the deployment default — a child whose header froze 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') + it('overrideOf reads the header baseline when the log has no own switch', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' }) - ctx.sandboxPolicy.stampOverride(child, 'read-only') - - const stamped = modeEvents(child) - expect(stamped).toHaveLength(1) - expect(stamped[0]?.data).toEqual({ mode: 'read-only' }) + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + // resolve() consumes the same chain, so enforcement sees the baseline. + expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') }) - it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => { + it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + // The fork seed carried the parent's OLD workspace-write switch (one + // event, so seedLength 1); the delegation-time baseline is read-only. + const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 }) + setSandboxMode(child, 'workspace-write') + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + // A switch the child makes ITSELF (after the seed boundary) outranks it. + setSandboxMode(child, 'danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access') + }) + + it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => { const ctx = await mounted() - const child = session('sess-inherit-dedup-child') - // A fork seed can already carry the parent's switch; stamping again would - // append a redundant event on every delegation. - setSandboxMode(child, 'read-only') + const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' }) - ctx.sandboxPolicy.stampOverride(child, 'read-only') - - expect(modeEvents(child)).toHaveLength(1) + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index ecaf2cea28..17a8695dcf 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/README.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 -README.md: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d -README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1 +README.md: 75540b957230676850a7abaf7a58877ff7dcbcc5 +README.zh.md: d82d5f8d0ef263aa3506ed56b7a2b0a83a1cb99d diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index a0d718cf8b..75540b9572 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index 307bef8efb..d82d5f8d0e 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或仅在 `packChunks` 下写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5018e19dbf..0d566d37c5 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -38,6 +38,8 @@ export interface HeaderLine { parentSession?: SessionId seedLength?: number delegationDepth: number + sandboxMode?: string + approvalPolicy?: string } /** @@ -55,6 +57,8 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, delegationDepth: header.delegationDepth ?? 0, + ...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {}, + ...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {}, } } @@ -72,6 +76,8 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, delegationDepth: line.delegationDepth, + ...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {}, + ...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {}, } } @@ -90,6 +96,10 @@ function isHeaderLine(value: unknown): value is HeaderLine { && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) + && ((value as { sandboxMode?: unknown }).sandboxMode === undefined + || typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string') + && ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined + || typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string') ) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index f771b9e3a7..8d98fbdf2d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -301,15 +301,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, parent_session = excluded.parent_session, seed_length = excluded.seed_length, - delegation_depth = excluded.delegation_depth + delegation_depth = excluded.delegation_depth, + sandbox_mode = excluded.sandbox_mode, + approval_policy = excluded.approval_policy `).run( meta.id, meta.version, @@ -319,6 +321,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.delegationDepth ?? null, randomUUID(), + meta.sandboxMode ?? null, + meta.approvalPolicy ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..f8e3285ba2 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 10 +export const SCHEMA_VERSION = 11 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -41,6 +41,10 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null + /** The inherited sandbox-mode delegation baseline, or NULL. */ + sandbox_mode: string | null + /** The inherited approval-policy delegation baseline, or NULL. */ + approval_policy: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -124,7 +128,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, delegation_depth INTEGER, incarnation TEXT NOT NULL, - revision INTEGER NOT NULL + revision INTEGER NOT NULL, + sandbox_mode TEXT, + approval_policy TEXT ) STRICT; CREATE TABLE IF NOT EXISTS events ( @@ -181,6 +187,8 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {}, + ...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 09d43d0c7f..e0455b659c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -170,6 +170,8 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, + sandbox_mode: null, + approval_policy: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) }) @@ -606,7 +608,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(10) + expect(SCHEMA_VERSION).toBe(11) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 227a742f7a..ed247edadb 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.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 -README.md: 31b917f7edc08182c72b1e4c0ed403a155dc477f -README.zh.md: 81cce8f804f5d6cb4a4915f3aecca278dd357db1 +README.md: 6563fbda00c8fc9a22be5f34ffb327d5876e1c05 +README.zh.md: f0cd20b3a5c40cb76141ff1fb0b6cd1f5f6b59f5 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 31b917f7ed..6563fbda00 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,7 +18,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. 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)). +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 carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 81cce8f804..f0cd20b3a5 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,7 +18,7 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 -子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——再由一个一次性、前置安装的 `agent/prompt-submit` 监听器通过 `stampOverride` 盖章写入捕获值(两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派)。锚定在子 agent 的第一个轮次内,使盖章事件被包围在轮次内(具备持久性)并先于第一次请求;前置安装使其位于具备否决能力的监听器之前,因此作出拒绝的 UserPromptSubmit 钩子无法在未盖章的情况下关闭第一个轮次;盖章事件在日志中位于 fork 初始内容携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析陈旧初始内容的时序。只复制覆盖链,因此未切换过的父 agent 不盖任何章,子 agent 继续跟随实时部署默认值。嵌套按构造即可组合:每次捕获折叠的都是发起委派的会话已经盖过章的日志(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值;`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1f02151a3b..d7df893ccc 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -100,16 +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 + // Policy inheritance: 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) + // The captured values ride the child's creation meta into its immutable + // header, so the baseline is durable from the moment the session exists — + // no first-turn event could survive every crash window (an idle injection + // can persist a complete turn before any prompt turn opens). Both services + // are consumed opportunistically — without them, delegation is policy-free. + const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) + const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { @@ -120,23 +121,6 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } - // 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 } @@ -148,6 +132,8 @@ export async function startInProcessRun( // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, ...seedLength > 0 ? { seedLength } : {}, + ...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {}, + ...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {}, }, ...options.seed !== undefined ? { seed: options.seed } : {}, agentOptions, diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 7de5fefc24..49dfa03423 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -198,16 +198,13 @@ describe('sandbox-mode inheritance against the real fs fence', () => { expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - // The stamped override is the child's OWN durable, turn-enclosed record: - // after turn/start, before the first model request snapshot. - const events = child.session.events - const turnStart = events.findIndex(e => e.type === 'turn/start') - const mode = events.findIndex(e => e.type === 'sandbox/mode') - const policy = events.findIndex(e => e.type === 'approval/policy') - const header = events.findIndex(e => e.type === 'request/header') - expect(mode).toBeGreaterThan(turnStart) - expect(policy).toBeGreaterThan(turnStart) - expect(header).toBeGreaterThan(mode) + // The inherited baseline is part of the child's IMMUTABLE header — + // durable from the creation moment, with no first-turn timing window + // (a crash after any persisted turn still resumes with the baseline). + expect(child.session.header.sandboxMode).toBe('read-only') + expect(child.session.header.approvalPolicy).toBe('never') + // The log stays free of stamped events: the header is the one home. + expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) // What the enforcing families resolve for the child, end to end. expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') // Inheritance reads the parent log, never writes it. @@ -216,6 +213,33 @@ describe('sandbox-mode inheritance against the real fs fence', () => { await run.dispose() }) + it('the baseline is durable BEFORE any child turn exists (the injection-turn crash window)', async () => { + // The review scenario: a SessionStart-style idle injection can persist a + // complete turn before the first prompt turn opens. The baseline must + // already be durable then — it is, because it rides the creation-time + // header, not a first-turn event. + const script: Script = [] + const { parent } = await setupWalled(script) + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + textResponse('child done'), + ) + parent.followup([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + + const run = await startInProcessRun(spawnRequest(parent), {}) + const child = run.localAgent as Agent + // Assert on the HEADER immediately after publication — before the child's + // first turn has run (run.result not yet awaited). An idle injection + // persisting a turn now would carry the baseline with it. + expect(child.session.header.sandboxMode).toBe('read-only') + await run.result + await run.dispose() + }) + it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => { const script: Script = [] const captured: Agent[] = [] @@ -373,10 +397,11 @@ describe('inheritance survives prompt vetoes', () => { 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 }) + // The veto closed the first turn promptless, but the baseline rides the + // creation-time header — no listener ordering can starve it, and a later + // resume must not fall back to the deployment default just because the + // first prompt was blocked. + expect(child.session.header.sandboxMode).toBe('read-only') expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') await run.dispose() @@ -399,7 +424,9 @@ describe('inheritance guards (must hold before AND after the fix)', () => { // workspace-write (the deployment default) really allowed the write… expect(await readFile(allowed, 'utf8')).toBe('fine') - // …and nothing froze that default into the child log. + // …and nothing froze that default into the child header or log. + expect(child.session.header.sandboxMode).toBeUndefined() + expect(child.session.header.approvalPolicy).toBeUndefined() expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) await run.dispose() diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 219d56beab..da878f5e88 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/README.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 -README.md: c954333cc832f4fe9228f94fec36f4fbea4770fd -README.zh.md: d0955971fb9e72a08296b7c7f9f96d6d24d13044 +README.md: 537fda6e16c2e21d22a1809e1e91fc0c3ddeee94 +README.zh.md: 132b145ee3c7f7cea7aa0529358ed4982615499f diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index c954333cc8..537fda6e16 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,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 automation bridge supplies one-shot machine decisions for sessions it owns. -`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)). +`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)` resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([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 automation bridge answers calls for its own agents through the client's machine policy. 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/README.zh.md b/packages/ui/user-approval/README.zh.md index d0955971fb..132b145ee3 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`/`ctx.approval.stampOverride(child, policy)` 是委派继承的两半:仅折叠本身(绝不包含配置默认值),以及通过该写入路径写入捕获的覆盖项;进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使 `'never'` 父级无法造出会弹出提示的子 agent(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)` 解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 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 06f94a5ddb..e3351a9771 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -323,35 +323,33 @@ export class ApprovalService extends Service { * @returns the policy every ask for this session resolves under right now. */ private effectivePolicy(session: Session): ApprovalPolicy { - return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask' + return this.overrideOf(session) ?? this.config.policy ?? 'ask' } /** - * 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. + * A session's approval-policy OVERRIDE — the override chain alone, never + * the configured default: the fold of the session's OWN switches (events + * past the seed boundary — a fork seed's stale parent switch is subsumed by + * the baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so a `'never'` (headless/CI) parent cannot + * mint children that fall back to a prompting default, at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the configured default. + * @throws when the durable header baseline is outside the closed policy + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ 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. 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 child - the child session the override is appended to. - * @param policy - the captured {@link overrideOf} value to stamp. - */ - stampOverride(child: Session, policy: ApprovalPolicy): void { - if (effectiveApprovalPolicy(child.events) === policy) return - setApprovalPolicy(child, policy) + const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) + if (own !== undefined) return own + const baseline = session.header.approvalPolicy + if (baseline === undefined) return undefined + if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { + throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) + } + return baseline as ApprovalPolicy } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 9b1664c27c..849185daee 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -20,6 +20,9 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty const agent = { session: { events: seed, + // The typed Session contract the service folds over includes the header + // (seed boundary + inherited baselines); the stub carries a bare one. + header: { version: 0, id: 'fake-session', createdAt: 0 }, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -577,14 +580,24 @@ describe('approval policy (the approval/policy fold)', () => { }) }) -describe('delegation inheritance (overrideOf + stampOverride)', () => { - const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy') - +describe('delegation inheritance (overrideOf over the header baseline)', () => { function bareSession(id: string): Session { return new Session(SessionId(id)) } - it('overrideOf folds to the LAST override and never falls back to the configured default', async () => { + /** A session whose header carries the delegation-inheritance baseline. */ + function inheritedSession(id: string, meta: { approvalPolicy?: string; seedLength?: number } = {}): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...meta.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, + ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + }) + } + + it('overrideOf folds the session log and never falls back to the configured default', async () => { const ctx = await mounted() const parent = bareSession('sess-appr-inherit-parent') setApprovalPolicy(parent, 'never') @@ -593,24 +606,34 @@ describe('delegation inheritance (overrideOf + stampOverride)', () => { expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() }) - it('stampOverride appends the captured policy through the canonical write path', async () => { + it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => { const ctx = await mounted() - const child = bareSession('sess-appr-inherit-child') + const child = inheritedSession('sess-appr-baseline', { approvalPolicy: 'never' }) - ctx.approval.stampOverride(child, 'never') - - const stamped = policyEvents(child) - expect(stamped).toHaveLength(1) - expect(stamped[0]?.data).toEqual({ policy: 'never' }) + expect(ctx.approval.overrideOf(child)).toBe('never') + // The request path consumes the same chain: an inherited 'never' rejects + // deterministically before any answerer could run. + child.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session: child } as unknown as Agent + await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('rejected') }) - it('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => { + it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { const ctx = await mounted() - const child = bareSession('sess-appr-dedup-child') - setApprovalPolicy(child, 'never') + const child = inheritedSession('sess-appr-slice', { approvalPolicy: 'never', seedLength: 1 }) + // Event 0 sits inside the seed boundary — stale parent history, subsumed + // by the delegation-time baseline. + setApprovalPolicy(child, 'ask') + expect(ctx.approval.overrideOf(child)).toBe('never') + // Event 1 is the child's OWN switch — it outranks the baseline. + setApprovalPolicy(child, 'ask') + expect(ctx.approval.overrideOf(child)).toBe('ask') + }) - ctx.approval.stampOverride(child, 'never') + it('rejects a header baseline outside the closed policy vocabulary (durable boundary)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-invalid', { approvalPolicy: 'always' }) - expect(policyEvents(child)).toHaveLength(1) + expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) })