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 1de32450b5..b0411bbf88 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: a15a20aeb8324f00fcb9ca85aac5d5e2352dfac3 -2026-07-25-subagent-policy-inheritance.zh.md: 2ac844fdd9bbb1b4a57e76a96a5cd5270c23d2aa +2026-07-25-subagent-policy-inheritance.md: 11086232bfe6a81ce5579d9459187f2931b6e387 +2026-07-25-subagent-policy-inheritance.zh.md: 63b2ebb2373b7a94f1b630df98b38325a045b9c0 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 a15a20aeb8..11086232bf 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 @@ -13,7 +13,7 @@ Session policy overrides are per-session log folds: the effective sandbox mode i 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, 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. +- **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. - **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. 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 2ac844fdd9..63b2ebb237 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 @@ -13,7 +13,7 @@ Status: implemented 共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: - **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 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`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **只复制覆盖链,且由策略 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` 仍然只留给真实的运行时切换。 - **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9880761894..c6b2a68258 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:198`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:221`](../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 2a137c2f70..6804e87819 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -263,7 +263,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:213`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:236`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -781,13 +781,18 @@ Owns the deployment's permission presets and their write path. Requires a confin ```ts cordis-catalog /** - * Resolve the preset matching the effective knob values. A still-matching - * last selection wins shared-bundle ties; otherwise the first table match - * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * Resolve the preset matching the effective knob values — the same + * 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. + * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Resolve a preset's knob bundle. @@ -815,7 +820,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) +Types: [Session](../core-data-structures/session.md) Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a586702cc7..b801d47443 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -399,8 +399,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Owns the deployment\'s permission presets and their write path.', methods: [ { - signature: 'current(events: readonly SessionEvent[]): string', - jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + 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 */', }, { signature: 'resolve(name: string): PresetSpec', diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 778b213f3d..b93b3a3311 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: 733a14141a8a67728b026c7a19d18266b61cbf6a -README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5 +README.md: 2088574df7145b72c130bdc4f8f841517afb004b +README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 733a14141a..2088574df7 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 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: 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 916baf00b6..e44ab8949f 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)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`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 46124f2373..317d0f971e 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,9 +19,9 @@ 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 { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts' +import { sandboxOverrideOf } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, effectiveSandboxMode, sandboxOverrideOf, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -121,14 +121,7 @@ export class SandboxPolicyService extends Service { * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined { - 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 + return sandboxOverrideOf(session) } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index a97532b29b..28a4234b26 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -54,6 +54,29 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo return undefined } +/** + * 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. + * @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 header baseline is outside the closed mode vocabulary. + */ +export function sandboxOverrideOf(session: Session): SandboxMode | undefined { + const baseline = session.header.sandboxMode + if (baseline !== undefined && !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 +} + /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 590497563b..3d1f1ae47d 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -195,4 +195,14 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) + + it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-inherit-invalid-own', { sandboxMode: 'yolo' }) + // A corrupt or foreign durable record must fail loud on EVERY read — an + // own override must not paper over the malformed header. + setSandboxMode(child, 'read-only') + + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) + }) }) diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index f78e944b02..893fd4e342 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -38,6 +38,14 @@ "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index c29bf60910..e7ff3fa2a7 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/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: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa -README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e +README.md: 2d01844d8391a530ec06878c0b77b20bf74d6f12 +README.zh.md: 4456f53291b64ce14a6eaea5204050ecfc043560 diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 6a59ad9425..2d01844d83 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. Both it and `current(session)` resolve the knobs through the same override chains execution reads (`sandboxOverrideOf`/`approvalOverrideOf`: own post-seed switches, else the inherited header baseline, else composition defaults), so a delegated child inheriting a wider baseline gets real knob switches when a narrower preset is selected, and a seed-carried selection is subsumed by the baseline. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` prefers a still-matching recorded own selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 0e7db1bd41..4456f53291 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -4,7 +4,7 @@ 通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。它与 `current(session)` 都通过执行所读取的同一套覆盖链解析调节项(`sandboxOverrideOf`/`approvalOverrideOf`:先取会话自己在种子之后的切换,否则取会话头中继承的基线,否则取组合默认值),因此继承了更宽基线的被委派子 agent(智能体)在选中更窄的 preset 时会得到真实的调节项切换,而种子携带的选择会被基线所涵盖。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 优先返回仍与当前调节项匹配的、会话自己的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 -- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 +- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d44dff3df4..8d89472abd 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -12,12 +12,12 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SANDBOX_MODES, sandboxOverrideOf, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.bash` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, approvalOverrideOf, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' declare module 'cordis' { interface Context { @@ -139,17 +139,22 @@ export class PermissionService extends Service { } /** - * Resolve the preset matching the effective knob values. A still-matching - * last selection wins shared-bundle ties; otherwise the first table match - * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * Resolve the preset matching the effective knob values — the same + * 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. + * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ - current(events: readonly SessionEvent[]): string { - const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode - const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' + current(session: Session): string { + 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(events) + const folded = effectivePermissionPreset(session.events.slice(session.header.seedLength ?? 0)) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded @@ -197,14 +202,17 @@ export class PermissionService extends Service { */ set(session: Session, name: string): void { const spec = this.resolve(name) - if (this.current(session.events) !== name) { + if (this.current(session) !== name) { session.append('permission/preset', { preset: name }) } - const events = session.events - if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) { + // Compare against the SAME override chains current() derives from: a + // child inheriting a wider baseline must get real knob switches when the + // user selects a narrower preset — an event-only fold would believe the + // preset is already active and silently leave enforcement at the baseline. + if (spec.sandbox !== (sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask')) { setApprovalPolicy(session, spec.approval) } } diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 864f25629d..4133ee7bbf 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -48,25 +48,25 @@ describe('PermissionService', () => { it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => { const ctx = await mounted() const session = freshSession('sess-current') - expect(ctx.permission.current(session.events)).toBe('workspace-write') + expect(ctx.permission.current(session)).toBe('workspace-write') ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') }) it('a knob state matching no table entry derives custom — a state, not an error', async () => { const ctx = await mounted() const session = freshSession('sess-custom') session.append('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) it('composition defaults outside the table derive custom at zero events', async () => { const ctx = await mounted({ approvalDefault: 'never' }) const session = freshSession('sess-defaults-custom') - expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) }) it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { @@ -77,10 +77,10 @@ describe('PermissionService', () => { } } }) const session = freshSession('sess-tie') ctx.permission.set(session, 'agentish') - expect(ctx.permission.current(session.events)).toBe('agentish') + expect(ctx.permission.current(session)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -140,6 +140,47 @@ describe('PermissionService', () => { const session = freshSession('sess-standin') ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permission.current(session.events)).toBe('workspace-write') + expect(ctx.permission.current(session)).toBe('workspace-write') + }) + + it('derives current() from an inherited header baseline and switches AWAY from it for real', async () => { + const ctx = await mounted() + // A delegated child: danger-full-access baseline over the composition's + // workspace-write/ask defaults — the child header, not the event log, + // carries the effective knobs. + const id = SessionId('sess-inherited-preset') + const child = new Session(id, undefined, { + version: 0, + id, + createdAt: 0, + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + }) + expect(ctx.permission.current(child)).toBe('danger-full-access') + + // Selecting workspace-write must APPEND both knob switches: folding only + // events would believe workspace-write is already active and silently + // leave enforcement at the inherited danger-full-access. + ctx.permission.set(child, 'workspace-write') + expect(child.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) + expect(child.events.some(e => e.type === 'approval/policy' && e.data.policy === 'ask')).toBe(true) + expect(ctx.permission.current(child)).toBe('workspace-write') + }) + + it('ignores a seed-carried preset selection in favor of the delegation baseline', async () => { + const ctx = await mounted() + const id = SessionId('sess-seeded-preset') + const seeded = new Session(id, undefined, { + version: 0, + id, + createdAt: 0, + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + seedLength: 1, + }) + // The fork seed carried the PARENT's old selection event; the baseline + // captured after it owns the child's truth. + seeded.append('permission/preset', { preset: 'workspace-write' }) + expect(ctx.permission.current(seeded)).toBe('danger-full-access') }) }) diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index da878f5e88..bf32de8dac 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: 537fda6e16c2e21d22a1809e1e91fc0c3ddeee94 -README.zh.md: 132b145ee3c7f7cea7aa0529358ed4982615499f +README.md: f6eaf049a46bc2faa7e0b12809d3d8c63d7792d6 +README.zh.md: a286d13eb7c5a3e7893a8269c6d50175eff47046 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 537fda6e16..f6eaf049a4 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)` 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: 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 132b145ee3..a286d13eb7 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)` 解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`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 消费)解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`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 e3351a9771..4a6d40ca1c 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -138,6 +138,29 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } +/** + * 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. + * @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 header baseline is outside the closed policy vocabulary. + */ +export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { + const baseline = session.header.approvalPolicy + if (baseline !== undefined && !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 +} + /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -342,14 +365,7 @@ export class ApprovalService extends Service { * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined { - 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 + return approvalOverrideOf(session) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 849185daee..f4ada96338 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -636,4 +636,12 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) + + it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-invalid-own', { approvalPolicy: 'always' }) + setApprovalPolicy(child, 'never') + + expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) + }) })