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')
})
})