mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)`(即纯函数导出 `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 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>" (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 策略提示词段
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user