subagent: carry inherited policy overrides in the child session header

Review fix (ds-review-bot critical #2 on #623): the first-turn event stamp
had a durability hole no turn anchoring can close — an idle SessionStart-
style injection persists a complete one-shot turn before any prompt turn
opens, so a crash in that window left a resumable-looking child with no
inherited policy, falling back to a possibly wider deployment default.

The captured overrides now ride the child's creation meta into its
immutable SessionHeader (sandboxMode/approvalPolicy, neutral strings at the
session boundary — the delegationDepth precedent), durable from the moment
the session exists: no listener ordering can starve the baseline and no
crash window can lose it. overrideOf(session) on both policy services
resolves fold(events past header.seedLength) ?? header baseline, validating
against the closed vocabulary on read; stampOverride and the prompt-submit
listener machinery are deleted. The header field rides both persistence
backends (JSONL header line; SQLite sessions columns, SCHEMA_VERSION 11 —
pre-release, no migration). pty-local reads through overrideOf so PTY
spawns see the baseline too.

Red-first: header-durability-before-any-turn test (the injection crash
window shape), baseline/seed-boundary/closed-vocabulary contract tests in
both service suites; the real-wall suite (race, veto, fork stale-seed,
grandchild) re-anchored on header assertions and green. The Agent Note's
Alternatives now records the superseded event-stamping iteration with the
review evidence; bilingual docs updated.
This commit is contained in:
kingwl
2026-07-26 18:16:45 +08:00
parent 166628c0b3
commit c53e9c90db
41 changed files with 387 additions and 264 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: c954333cc832f4fe9228f94fec36f4fbea4770fd
README.zh.md: d0955971fb9e72a08296b7c7f9f96d6d24d13044
README.md: 537fda6e16c2e21d22a1809e1e91fc0c3ddeee94
README.zh.md: 132b145ee3c7f7cea7aa0529358ed4982615499f

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)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps inside the child's first turn so a `'never'` parent cannot mint prompting children ([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. `ctx.approval.overrideOf(session)` resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; 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).

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)``ctx.approval.stampOverride(child, policy)` 委派继承的两半:仅折叠本身(绝不包含配置默认值),以及通过该写入路径写入捕获的覆盖项;进程内 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)` 解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 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)。

View File

@@ -323,35 +323,33 @@ export class ApprovalService extends Service {
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
}
/**
* A session's approval-policy OVERRIDE — the fold alone, never the
* configured default. The read half of delegation inheritance: the subagent
* driver captures this synchronously at delegation, so a parent switch
* racing the child's asynchronous creation belongs to the parent's future,
* not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to fold.
* @returns the last switched policy, or `undefined` for a never-switched session.
* A session's approval-policy OVERRIDE — the override chain alone, never
* the configured default: the fold of the session's OWN switches (events
* past the seed boundary — a fork seed's stale parent switch is subsumed by
* the baseline captured after it), else the header's inherited delegation
* baseline. The subagent driver stamps `overrideOf(parent.session)` into
* each child's creation meta, so a `'never'` (headless/CI) parent cannot
* mint children that fall back to a prompting default, at any depth
* ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @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 durable header baseline is outside the closed policy
* vocabulary (a corrupt or foreign log; durable-boundary validation).
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
return effectiveApprovalPolicy(session.events)
}
/**
* Stamp a captured override onto a child session through the canonical
* write path — the write half of delegation inheritance: a `'never'`
* (headless/CI) parent must not mint children that fall back to a prompting
* default. A child whose log (e.g. a fork seed) already folds to the policy
* is left untouched. Callers must append inside an open child turn — a bare
* between-turn event is crash-tail garbage on reload.
* @param child - the child session the override is appended to.
* @param policy - the captured {@link overrideOf} value to stamp.
*/
stampOverride(child: Session, policy: ApprovalPolicy): void {
if (effectiveApprovalPolicy(child.events) === policy) return
setApprovalPolicy(child, policy)
const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0))
if (own !== undefined) return own
const baseline = session.header.approvalPolicy
if (baseline === undefined) return undefined
if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`)
}
return baseline as ApprovalPolicy
}
/**

View File

@@ -20,6 +20,9 @@ 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
@@ -577,14 +580,24 @@ describe('approval policy (the approval/policy fold)', () => {
})
})
describe('delegation inheritance (overrideOf + stampOverride)', () => {
const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy')
describe('delegation inheritance (overrideOf over the header baseline)', () => {
function bareSession(id: string): Session {
return new Session(SessionId(id))
}
it('overrideOf folds to the LAST override and never falls back to the configured default', async () => {
/** 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')
@@ -593,24 +606,34 @@ describe('delegation inheritance (overrideOf + stampOverride)', () => {
expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined()
})
it('stampOverride appends the captured policy through the canonical write path', async () => {
it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => {
const ctx = await mounted()
const child = bareSession('sess-appr-inherit-child')
const child = inheritedSession('sess-appr-baseline', { approvalPolicy: 'never' })
ctx.approval.stampOverride(child, 'never')
const stamped = policyEvents(child)
expect(stamped).toHaveLength(1)
expect(stamped[0]?.data).toEqual({ policy: '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('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => {
it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => {
const ctx = await mounted()
const child = bareSession('sess-appr-dedup-child')
setApprovalPolicy(child, 'never')
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')
})
ctx.approval.stampOverride(child, 'never')
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(policyEvents(child)).toHaveLength(1)
expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/)
})
})