policy: guard adoption baselines, resolve-time validation, and inherited-delta narration

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

- Persistence adoption compares the immutable policy baselines: onCreated's
  ownerless claim and adoptLivePrefix retain the STORED header, so a
  same-id live session with a conflicting baseline now rejects as a
  collision instead of appending under read-only and resuming under the
  stored danger-full-access.
- resolve() resolves the session override BEFORE applying an explicit
  approved mode: the one-shot grant no longer bypasses the unconditional
  durable-header validation.
- The approval narrator attributes positionally over the session's OWN
  events (past the seed boundary): a fork child whose baseline delta has
  no own override narrates 'inherited from the delegating session' instead
  of misattributing a stale seed-carried switch to the user or the
  operator.

Red-first: baseline-conflict adoption in the shared coordinator contract
(both backends), resolve-with-explicit-mode validation, and the fork-child
narration attribution case.
This commit is contained in:
kingwl
2026-07-26 23:56:41 +08:00
parent 9aaa4a871f
commit 290e1acc45
9 changed files with 105 additions and 15 deletions

View File

@@ -99,8 +99,12 @@ export class SandboxPolicyService extends Service {
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
const { session } = request
// Resolve the session override FIRST even when an explicit approved mode
// outranks it: the unconditional durable-header validation must hold on
// every resolution — a one-shot grant is not a validation bypass.
const override = session === undefined ? undefined : this.overrideOf(session)
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
mode: request.mode ?? override ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
}
}

View File

@@ -229,4 +229,13 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => {
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/)
})
it('resolve() validates the durable header even when an explicit approved mode is supplied', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-resolve-invalid', { sandboxMode: 'yolo' })
// The explicit one-shot grant must not become a validation bypass: the
// unconditional durable-header contract holds on EVERY resolution.
expect(() => ctx.sandboxPolicy.resolve({ session: child, mode: 'workspace-write' })).toThrow(/sandboxMode/)
})
})

View File

@@ -113,6 +113,22 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
return errors
}
/**
* Reject a stored/live pair whose immutable policy baselines differ.
* Adoption and ownerless claims retain the STORED header, so accepting a
* conflicting pair would let a session run under its live baseline now but
* resume under the stored one later — a silent policy swap.
*/
function assertSamePolicyBaselines(id: SessionId, stored: SessionHeader, live: SessionHeader): void {
if (stored.sandboxMode !== live.sandboxMode || stored.approvalPolicy !== live.approvalPolicy) {
throw new Error(
`session "${id}" is already persisted with a different policy baseline `
+ `(persisted: ${String(stored.sandboxMode)}/${String(stored.approvalPolicy)}, `
+ `live: ${String(live.sandboxMode)}/${String(live.approvalPolicy)}) (id collision)`,
)
}
}
/** Whether a live session seed reproduces a persisted prefix exactly. */
function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
return prefix.length <= seed.length
@@ -534,6 +550,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (tracked.meta.cwd !== session.header.cwd) {
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
assertSamePolicyBaselines(id, tracked.meta, session.header)
if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) {
throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`)
}
@@ -587,6 +604,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (meta.cwd !== session.header.cwd) {
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
assertSamePolicyBaselines(session.header.id, meta, session.header)
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {

View File

@@ -679,6 +679,27 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
// A stored artifact carrying a WIDE baseline. A same-id live session
// claiming a NARROW baseline must be rejected: adoption retains the
// stored header, so accepting the pair would let the session append
// under read-only now but resume under danger-full-access later.
await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' })
await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog())
const live = ctx.sessions.create(SessionId('baseline-conflict'), {
seed: oneTurnLog(),
meta: { cwd: WORK, sandboxMode: 'read-only' },
})
await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)

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: 978a397f3f48b4e20461c2486e0611313526abf9
README.zh.md: 96bbb13c0fbe555faa3dcca44caf31227c505a41
README.md: 251782ac19de54413e6e318d141f39f964900a32
README.zh.md: 9fab4f5cb73bb893d7c55a2e65c43c96eb9dfa4c

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 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: 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 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)).
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).` 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).`, `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.
##### Ask-policy prompt section

View File

@@ -8,7 +8,7 @@
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求每项部署应当组合一个终端应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其拥有的会话提供一次性机器决定。
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`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` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`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))。
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Noteagent 决策记录)](../../../.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>" (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>" (inherited from the delegating session).``The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`
##### Ask 策略提示词段

View File

@@ -276,17 +276,21 @@ 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: 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).
// 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).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
const session = agent.session
const events = session.events
const seedStart = Math.min(session.header.seedLength ?? 0, events.length)
let overrideIndex = -1
let headerIndex = -1
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
for (let index = events.length - 1; index >= seedStart && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
@@ -294,8 +298,8 @@ export class ApprovalService extends Service {
headerIndex = index
}
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
// Same fold effectivePolicy performs — the own 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)
@@ -303,7 +307,11 @@ 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' : 'changed by the operator/config'
const cause = overrideIndex > headerIndex
? 'changed by the user'
: overrideIndex < 0 && session.header.approvalPolicy === current
? 'inherited from the delegating session'
: 'changed by the operator/config'
agent.inject(
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
{ source: { kind: 'plugin', plugin: 'user-approval' } },

View File

@@ -517,6 +517,36 @@ 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: (content: { type: string; text: string }[]) => { injected.push(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' })