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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 必须重新加载插件。
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user