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