policy: resolve every knob consumer through the shared override chain

Review fixes (ds-review-bot warnings on #623):

- One chain, every consumer: the override resolution (own post-seed
  switches ?? header baseline, closed-vocabulary validated) moves into pure
  exports (sandboxOverrideOf / approvalOverrideOf); the services delegate,
  and the permission presets consume them — current(session) and set()
  now see inherited baselines, so a child inheriting danger-full-access
  gets REAL knob switches when workspace-write is selected instead of a
  silent no-op, and a seed-carried preset selection is subsumed by the
  baseline. current(events) becomes current(session) (pre-release; the
  only callers were tests).
- Unconditional durable validation: a malformed header baseline fails
  loud on every read, no longer shadowed by an own switch.
- The two policy peers are declared optional (peerDependenciesMeta), so a
  thin spawn/fork deployment without policy plugins can consume the
  driver; verify-runtime-closure honors the flag.

Red-first: inherited-preset derive/switch-away and seeded-selection tests
in the permission suite; malformed-baseline-with-own-switch tests in both
policy suites.
This commit is contained in:
kingwl
2026-07-26 22:02:02 +08:00
parent ffb6435a41
commit f505bd9258
23 changed files with 179 additions and 67 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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).
+2 -2
View File
@@ -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 必须重新加载插件。
+22 -14
View File
@@ -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)
}
}
@@ -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')
})
})