subagent: seed inherited policy events at creation

The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch.

Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults.

Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly.

This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
This commit is contained in:
Tianyi Cui
2026-07-28 21:31:17 +08:00
parent afa38c4b2f
commit cfceb8452b
55 changed files with 415 additions and 1371 deletions

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: 2d01844d8391a530ec06878c0b77b20bf74d6f12
README.zh.md: 4456f53291b64ce14a6eaea5204050ecfc043560
README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa
README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e

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. 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.
`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.
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).

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。它与 `current(session)` 都通过执行所读取的同一套覆盖链解析调节项(`sandboxOverrideOf`/`approvalOverrideOf`:先取会话自己在种子之后的切换,否则取会话头中继承的基线,否则取组合默认值),因此继承了更宽基线的被委派子 agent(智能体)在选中更窄的 preset 时会得到真实的调节项切换,而种子携带的选择会被基线所涵盖。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 优先返回仍与当前调节项匹配的、会话自己的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `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 必须重新加载插件。

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, sandboxOverrideOf, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SANDBOX_MODES, effectiveSandboxMode, 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, approvalOverrideOf, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
declare module 'cordis' {
interface Context {
@@ -139,24 +139,17 @@ export class PermissionService extends Service {
}
/**
* 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 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.
* 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.
* @returns the effective preset name, or `custom` when nothing matches.
*/
current(session: Session): string {
const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode
const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask'
current(events: readonly SessionEvent[]): string {
const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
const delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined
const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events)
const folded = effectivePermissionPreset(events)
if (folded !== undefined) {
const spec = this.presets[folded]
if (spec !== undefined && matches(spec)) return folded
@@ -204,17 +197,14 @@ export class PermissionService extends Service {
*/
set(session: Session, name: string): void {
const spec = this.resolve(name)
if (this.current(session) !== name) {
if (this.current(session.events) !== name) {
session.append('permission/preset', { preset: name })
}
// 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)) {
const events = session.events
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
setSandboxMode(session, spec.sandbox)
}
if (spec.approval !== (approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask')) {
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
setApprovalPolicy(session, spec.approval)
}
}

View File

@@ -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)).toBe('workspace-write')
expect(ctx.permission.current(session.events)).toBe('workspace-write')
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session)).toBe('danger-full-access')
expect(ctx.permission.current(session.events)).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)).toBe(CUSTOM_PRESET)
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session)).toBe('danger-full-access')
expect(ctx.permission.current(session.events)).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)).toBe(CUSTOM_PRESET)
expect(ctx.permission.current(session.events)).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)).toBe('agentish')
expect(ctx.permission.current(session.events)).toBe('agentish')
session.append('approval/policy', { policy: 'never' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session)).toBe('danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
})
it('set() writes through: one preset event plus both knob events', async () => {
@@ -140,47 +140,6 @@ describe('PermissionService', () => {
const session = freshSession('sess-standin')
ctx.permission.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
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')
expect(ctx.permission.current(session.events)).toBe('workspace-write')
})
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 packages/ui/user-approval/README.md
README.md: c29ada50950cb6b40065a09bac1becbf4afb0ce4
README.zh.md: c119f2009298edfaa101b10c7562d0c36324ec5d
# pnpm run verify-translation-pairing --write
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782

View File

@@ -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 session's override chain (`approvalOverrideOf`, below), 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 positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, 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)).
`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.
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).
@@ -18,7 +18,7 @@ The tools pipeline routes `ask` decisions through this seam and fails closed whe
#### What the model sees
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).`, `The approval policy changed from "<old>" to "<new>" (inherited from the delegating session).`, or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
##### Ask-policy prompt section

View File

@@ -8,7 +8,7 @@
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取会话的覆盖链(见下文 `approvalOverrideOf`),并回退到配置;`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))。
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。
工具流水线通过此 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)。
@@ -18,7 +18,7 @@
#### 模型看到的内容
在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).`、`The approval policy changed from "<old>" to "<new>" (inherited from the delegating session).` 或 `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`。
在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).` 或 `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`。
##### Ask 策略提示词段

View File

@@ -59,13 +59,16 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The last such OWN
* (post-seed) event is the session's override
* ({@link approvalOverrideOf}); who asked for it is derivable from
* position (an own event after the log's last own `request/header` was a
* runtime switch by the user).
* from the prompt section and the narrator's notices). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
*/
'approval/policy': { policy: ApprovalPolicy }
'approval/policy': {
policy: ApprovalPolicy
/** Marks an override seeded into a child at delegation. */
source?: 'delegation'
}
}
}
@@ -124,12 +127,10 @@ function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefi
}
/**
* The pure fold of a slice of `approval/policy` events: the last switch
* wins, or undefined without one. The building block
* {@link approvalOverrideOf} composes with the seed boundary and the header
* baseline — consumers resolving a SESSION's policy go through that chain,
* not this raw fold. Resume needs no catch-up machinery because replaying
* the log IS the state.
* The session's approval-policy override: the last `approval/policy` event in
* the log, or undefined when the session never switched (callers apply the
* plugin's configured default). The pure fold — resume needs no catch-up
* machinery because replaying the log IS the state.
* @param events - session events in log order (other event types are skipped).
* @returns the policy of the last switch event, or undefined without one.
*/
@@ -141,41 +142,6 @@ 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. 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.
* @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) return effectiveApprovalPolicy(session.events)
if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`)
}
// A boundary past the log would make the own-switch slice empty until the
// log grows past it — a baseline would then shadow a REAL later switch.
// Malformed durable metadata fails loud, never fails open.
const seedLength = session.header.seedLength ?? 0
if (seedLength > session.events.length) {
throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`)
}
const own = effectiveApprovalPolicy(session.events.slice(seedLength))
return own ?? baseline as ApprovalPolicy
}
/**
* Whether the log currently sits inside an open turn (a `turn/start` not yet
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
@@ -279,30 +245,28 @@ export class ApprovalService extends Service {
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional over the
// session's OWN events (past the seed boundary — a seed-carried switch is
// stale parent history, never this session's runtime action): an own
// override after the last own `request/header` was a runtime switch by
// the user; no own override with the delta matching the inherited header
// baseline came from the delegating session; otherwise the configured
// default moved under the session (operator/config).
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/step', (agent) => {
const session = agent.session
const events = session.events
const seedStart = Math.min(session.header.seedLength ?? 0, events.length)
let overrideIndex = -1
let overrideSource: 'delegation' | undefined
let headerIndex = -1
for (let index = events.length - 1; index >= seedStart && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
overrideSource = event.data.source
} else if (headerIndex < 0 && event.type === 'request/header') {
headerIndex = index
}
}
// Same fold effectivePolicy performs — the own override is scanned here
// anyway for POSITIONAL attribution; the default lives once, in the method.
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
@@ -310,11 +274,9 @@ export class ApprovalService extends Service {
// Cold start (nothing ever told) narrates nothing — the section about
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideIndex > headerIndex
? 'changed by the user'
: overrideIndex < 0 && session.header.approvalPolicy === current
? 'inherited from the delegating session'
: 'changed by the operator/config'
const cause = overrideSource === 'delegation'
? 'inherited from the delegating session'
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
@@ -362,9 +324,9 @@ export class ApprovalService extends Service {
}
/**
* The session's effective policy: its override chain ({@link overrideOf}),
* else the configured default (the schema already defaulted an omitted
* policy to `'ask'`; the `??` only narrows the optional-input TYPE).
* The session's effective policy: its own `approval/policy` fold, else the
* configured default (the schema already defaulted an omitted policy to
* `'ask'`; the `??` only narrows the optional-input TYPE).
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
*/
@@ -373,15 +335,12 @@ export class ApprovalService extends Service {
}
/**
* {@link approvalOverrideOf} surfaced on the service, for consumers that
* reach the seam through `ctx.get('approval')` (the subagent driver's
* delegation capture) rather than a value import.
* @param session - the session whose override chain to resolve.
* @returns the effective override, or `undefined` for a session following
* the configured default.
* Read the session override without applying the configured default.
* @param session - session whose log supplies the override.
* @returns the last logged policy, or `undefined` without one.
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
return approvalOverrideOf(session)
return effectiveApprovalPolicy(session.events)
}
/**

View File

@@ -20,9 +20,6 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty
const agent = {
session: {
events: seed,
// The typed Session contract the service folds over includes the header
// (seed boundary + inherited baselines); the stub carries a bare one.
header: { version: 0, id: 'fake-session', createdAt: 0 },
append: (type: string, data: Record<string, unknown>) => {
appended.push({ type, data })
return { type, data } as unknown as SessionEvent
@@ -459,7 +456,9 @@ describe('approval policy (the approval/policy fold)', () => {
await ctx.plugin(ApprovalService, { policy: 'never' })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const { agent, session } = sessionAgent('sess-gate-3')
expect(ctx.approval.overrideOf(session)).toBeUndefined()
setApprovalPolicy(session, 'ask')
expect(ctx.approval.overrideOf(session)).toBe('ask')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
setApprovalPolicy(session, 'never')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
@@ -510,6 +509,18 @@ describe('approval policy (the approval/policy fold)', () => {
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
it('narrates a config default drift from the logged ask marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
@@ -519,38 +530,6 @@ describe('approval policy (the approval/policy fold)', () => {
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
})
it('does not attribute a fork child\'s baseline delta to a stale seed-carried user switch', async () => {
// A fork child: the seed carries the parent's OLD 'ask' switch (event 0,
// inside seedLength) and the last request header told 'ask'; the header
// baseline captured at delegation is 'never'. The delta must not be
// attributed to "the user" — the seed switch is stale parent history, not
// this session's runtime action.
const ctx = new Context()
await ctx.plugin(ApprovalService)
const id = SessionId('sess-narr-fork-baseline')
const session = new Session(id, undefined, {
version: 0,
id,
createdAt: 0,
approvalPolicy: 'never',
seedLength: 2,
})
setApprovalPolicy(session, 'ask')
session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: `persona\n${ASK_MARKER}` }, reason: 'initial' })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
session,
inject: (input: { content: Array<{ type: string; text: string }> }) => {
injected.push(input.content[0]?.text ?? '')
},
} as unknown as Agent
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
it('a pinned override survives a default change silently', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
@@ -613,88 +592,3 @@ describe('approval policy (the approval/policy fold)', () => {
expect(afterDispose.injected).toEqual([])
})
})
describe('delegation inheritance (overrideOf over the header baseline)', () => {
function bareSession(id: string): Session {
return new Session(SessionId(id))
}
/** A session whose header carries the delegation-inheritance baseline. */
function inheritedSession(id: string, meta: { approvalPolicy?: string; seedLength?: number } = {}): Session {
const sessionId = SessionId(id)
return new Session(sessionId, undefined, {
version: 0,
id: sessionId,
createdAt: 0,
...meta.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy },
...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength },
})
}
it('overrideOf folds the session log and never falls back to the configured default', async () => {
const ctx = await mounted()
const parent = bareSession('sess-appr-inherit-parent')
setApprovalPolicy(parent, 'never')
expect(ctx.approval.overrideOf(parent)).toBe('never')
expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined()
})
it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-appr-baseline', { approvalPolicy: 'never' })
expect(ctx.approval.overrideOf(child)).toBe('never')
// The request path consumes the same chain: an inherited 'never' rejects
// deterministically before any answerer could run.
child.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = { session: child } as unknown as Agent
await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('rejected')
})
it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-appr-slice', { approvalPolicy: 'never', seedLength: 1 })
// Event 0 sits inside the seed boundary — stale parent history, subsumed
// by the delegation-time baseline.
setApprovalPolicy(child, 'ask')
expect(ctx.approval.overrideOf(child)).toBe('never')
// Event 1 is the child's OWN switch — it outranks the baseline.
setApprovalPolicy(child, 'ask')
expect(ctx.approval.overrideOf(child)).toBe('ask')
})
it('rejects a header baseline outside the closed policy vocabulary (durable boundary)', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-appr-invalid', { approvalPolicy: 'always' })
expect(() => 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/)
})
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')
})
it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-appr-oob', { approvalPolicy: 'ask', seedLength: 100 })
setApprovalPolicy(child, 'never')
expect(() => ctx.approval.overrideOf(child)).toThrow(/seedLength/)
})
})