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 0e4b5dbbd6..7084110b8d 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: 8609fee90f3f9ea73f5e6229a38cde5e61337144 -2026-07-25-subagent-policy-inheritance.zh.md: ad1b4650348154d82473b17d920dff475833f00e +2026-07-25-subagent-policy-inheritance.md: ea8e4626a1bd2ac31c2a7ce207bf9ba73e2e32fc +2026-07-25-subagent-policy-inheritance.zh.md: b9e978761af7de6ecf9a2e6ec167e2bb512c9ebf 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 8609fee90f..ea8e4626a1 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 @@ -14,7 +14,7 @@ The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent - **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)` — the pure `sandboxOverrideOf`/`approvalOverrideOf` exports, surfaced as service methods — 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 UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (`resolve()`, pty-local) and the permission presets (`current`/`set`), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (`ctx.get`, type-only imports, `peerDependenciesMeta.optional`): 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. +- **Fork stale-seed precedence falls out of the seed boundary — scoped to delegation children.** A fork seed may carry the parent's OLD switch events; when a header baseline exists, `overrideOf` folds only events past `header.seedLength` — the baseline was captured from the parent's FULL log at delegation, so seed-carried history is subsumed by it while a switch the child makes ITSELF still outranks it. Without a baseline (a top-level session, or a generic `SessionStore.fork` child that captured no policy meta) the fold covers the whole log: there, seeded switches ARE the replayed inherited truth, and slicing them away would silently widen the child to the deployment default. 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 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 ad1b465034..b9e978761a 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 @@ -14,7 +14,7 @@ Status: implemented - **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 - **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——即纯函数导出 `sandboxOverrideOf`/`approvalOverrideOf`,以服务方法的形式暴露——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都无条件按自己的封闭词汇校验(即便自己做出的切换会遮蔽基线,损坏的会话头也会大声失败),遇到词汇之外的值即抛出异常。每一个旋钮消费方都经由同一条链解析——强制执行侧(`resolve()`、pty-local)与权限 preset(`current`/`set`)皆然——因此当选中更窄的 preset 时,继承了更宽基线的子 agent 得到的是真实的旋钮切换,而非静默的空操作。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入,`peerDependenciesMeta.optional`):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由种子边界自然得出。**fork 种子可能携带父级旧的切换事件;`overrideOf` 只折叠 `header.seedLength` 之后的事件,因此种子携带的历史已被委派时的基线所涵盖,而子 agent 自己做出的切换仍然优先于基线。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 +- **fork 陈旧种子的优先级由种子边界自然得出——仅限委派子 agent。**fork 种子可能携带父级旧的切换事件;当会话头基线存在时,`overrideOf` 只折叠 `header.seedLength` 之后的事件——基线是在委派时从父级的完整日志捕获的,因此种子携带的历史已被它所涵盖,而子 agent 自己做出的切换仍然优先于基线。没有基线时(顶层会话,或未捕获任何策略元数据的通用 `SessionStore.fork` 子会话),折叠覆盖完整日志:此时种子携带的切换本身就是回放所得的继承事实,把它们切掉会把子会话静默放宽到部署默认值。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 - **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 ### 被拦住的子 agent 会经历什么 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c6b2a68258..4d12247385 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,7 +1862,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:221`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:226`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 024994840e..5ed0806257 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -256,7 +256,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined 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) -Source: [`packages/ui/user-approval/src/index.ts:236`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:241`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -778,10 +778,11 @@ Owns the deployment's permission presets and their write path. Requires a confin * override chains execution reads (own post-seed switches, else the * inherited header baseline, else the composition defaults), so a * delegated child's inherited knobs derive its real preset. A - * still-matching last OWN selection wins shared-bundle ties (a seed-carried - * selection is stale parent history, subsumed by the baseline); otherwise - * the first table match wins, or {@link CUSTOM_PRESET} when no entry - * matches. + * still-matching last selection wins shared-bundle ties, scoped like the + * knob chains: a delegation child (header baselines present) ignores + * seed-carried selections as stale parent history, while a generic fork + * child keeps them alongside its seed-carried knobs; otherwise the first + * table match wins, or {@link CUSTOM_PRESET} when no entry matches. * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0028a153fd..9db12a1e9e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -400,7 +400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'current(session: Session): string', - jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last OWN selection wins shared-bundle ties (a seed-carried\n * selection is stale parent history, subsumed by the baseline); otherwise\n * the first table match wins, or {@link CUSTOM_PRESET} when no entry\n * matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last selection wins shared-bundle ties, scoped like the\n * knob chains: a delegation child (header baselines present) ignores\n * seed-carried selections as stale parent history, while a generic fork\n * child keeps them alongside its seed-carried knobs; otherwise the first\n * table match wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, { signature: 'resolve(name: string): PresetSpec', diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index b93b3a3311..77822b4b58 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: 2088574df7145b72c130bdc4f8f841517afb004b -README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0 +README.md: ca6ee67ab0cdb645bba135b3ca4f542844306857 +README.zh.md: a0c43235de97872ef734f0bef3cc6ed16930e232 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 2088574df7..ca6ee67ab0 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)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — 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)). +- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: with an inherited `sandboxMode` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary); without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so seed-carried switches remain the replayed inherited truth. 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 e44ab8949f..a0c43235de 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)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:当存在继承的 `sandboxMode` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界);没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的切换仍是回放所得的继承事实。进程内 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/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 28a4234b26..93ba463d30 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -56,13 +56,17 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo /** * The session's complete sandbox-mode OVERRIDE chain — the one home every - * consumer (the policy service, the permission presets) resolves through: - * the fold of the session's OWN switches (events past the seed boundary — a - * fork seed's stale parent switch is subsumed by the delegation baseline - * captured after it), else the header's inherited baseline. Never the - * deployment default. The durable baseline is validated UNCONDITIONALLY — a - * corrupt or foreign header must fail loud on every read, not only when no - * own switch happens to shadow it. + * consumer (the policy service, the permission presets) resolves through. + * With a header baseline (a delegation child), the fold covers only the + * session's OWN switches past the seed boundary — the baseline was captured + * from the parent's FULL log at delegation, so any seed-carried switch is + * already subsumed by it, stale or not. Without a baseline (a top-level + * session, or a generic `SessionStore.fork` child that captured no policy + * meta), the fold covers the whole log: seeded switches ARE the replayed + * inherited truth, and slicing them away would silently widen the child to + * the deployment default. Never the deployment default itself. The durable + * baseline is validated UNCONDITIONALLY — a corrupt or foreign header must + * fail loud on every read, not only when no own switch happens to shadow it. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the deployment default. @@ -70,11 +74,12 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo */ export function sandboxOverrideOf(session: Session): SandboxMode | undefined { const baseline = session.header.sandboxMode - if (baseline !== undefined && !SANDBOX_MODES.includes(baseline as SandboxMode)) { + if (baseline === undefined) return effectiveSandboxMode(session.events) + if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) } const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) - return own ?? baseline as SandboxMode | undefined + return own ?? baseline as SandboxMode } /** diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 3d1f1ae47d..a0924c0d99 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -205,4 +205,17 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) + + it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + // The public fork path sets seedLength but captures no delegation + // baseline; the seed boundary must not discard the replayed policy state + // it exists to subsume — with nothing to subsume it, seeded switches ARE + // the child's inherited truth. + const child = inheritedSession('sess-generic-fork', { seedLength: 1 }) + setSandboxMode(child, 'read-only') + + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') + }) }) diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 8d89472abd..616a92c3f3 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -143,10 +143,11 @@ export class PermissionService extends Service { * override chains execution reads (own post-seed switches, else the * inherited header baseline, else the composition defaults), so a * delegated child's inherited knobs derive its real preset. A - * still-matching last OWN selection wins shared-bundle ties (a seed-carried - * selection is stale parent history, subsumed by the baseline); otherwise - * the first table match wins, or {@link CUSTOM_PRESET} when no entry - * matches. + * still-matching last selection wins shared-bundle ties, scoped like the + * knob chains: a delegation child (header baselines present) ignores + * seed-carried selections as stale parent history, while a generic fork + * child keeps them alongside its seed-carried knobs; otherwise the first + * table match wins, or {@link CUSTOM_PRESET} when no entry matches. * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ @@ -154,7 +155,8 @@ export class PermissionService extends Service { const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(session.events.slice(session.header.seedLength ?? 0)) + const delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined + const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index bf32de8dac..0d7efac766 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: f6eaf049a46bc2faa7e0b12809d3d8c63d7792d6 -README.zh.md: a286d13eb7c5a3e7893a8269c6d50175eff47046 +README.md: 978a397f3f48b4e20461c2486e0611313526abf9 +README.zh.md: 96bbb13c0fbe555faa3dcca44caf31227c505a41 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index f6eaf049a4..978a397f3f 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)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) 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)). +`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)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; 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 a286d13eb7..96bbb13c0f 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)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 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)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 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 2c44d674b5..2692bff997 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -141,12 +141,16 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv /** * The session's complete approval-policy OVERRIDE chain — the one home every * consumer (this service's policy tier, the permission presets) resolves - * through: the fold of the session's OWN switches (events past the seed - * boundary — a fork seed's stale parent switch is subsumed by the delegation - * baseline captured after it), else the header's inherited baseline. Never - * the configured default. The durable baseline is validated UNCONDITIONALLY — - * a corrupt or foreign header must fail loud on every read, not only when no - * own switch happens to shadow it. + * through. With a header baseline (a delegation child), the fold covers only + * the session's OWN switches past the seed boundary — the baseline was + * captured from the parent's FULL log at delegation, so any seed-carried + * switch is already subsumed by it. Without a baseline (a top-level session, + * or a generic `SessionStore.fork` child that captured no policy meta), the + * fold covers the whole log: seeded switches ARE the replayed inherited + * truth, and slicing them away would silently drop a forked `'never'`. Never + * the configured default itself. The durable baseline is validated + * UNCONDITIONALLY — a corrupt or foreign header must fail loud on every + * read, not only when no own switch happens to shadow it. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the configured default. @@ -154,11 +158,12 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv */ export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { const baseline = session.header.approvalPolicy - if (baseline !== undefined && !APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { + if (baseline === undefined) return effectiveApprovalPolicy(session.events) + if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) } const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) - return own ?? baseline as ApprovalPolicy | undefined + return own ?? baseline as ApprovalPolicy } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index f4ada96338..76a4afef5f 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -644,4 +644,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) + + it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { + const ctx = await mounted() + // The public fork path sets seedLength but captures no delegation + // baseline; with nothing to subsume them, seeded switches ARE the + // child's inherited truth — slicing would silently drop a forked 'never'. + const child = inheritedSession('sess-appr-generic-fork', { seedLength: 1 }) + setApprovalPolicy(child, 'never') + + expect(ctx.approval.overrideOf(child)).toBe('never') + }) })