policy: scope seed-boundary slicing to delegation children
Review fix (ds-review-bot on #623): the unconditional slice regressed the public SessionStore.fork path — a generic fork child gets seedLength but no policy baseline, so slicing discarded its seed-carried sandbox/approval switches and silently widened it to the deployment defaults (a forked read-only/never parent produced a workspace-write/ask child). overrideOf now branches on baseline presence: with a header baseline (a delegation child) the fold covers only own post-seed switches — the baseline captured from the parent's FULL log subsumes seed history; without one, the whole log — seeded switches ARE the replayed inherited truth. The permission preset fold scopes the same way. Red-first: generic-fork seed-carried override tests in both policy suites.
This commit is contained in:
@@ -400,7 +400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'current(session: Session): string',
|
||||
jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last OWN selection wins shared-bundle ties (a seed-carried\n * selection is stale parent history, subsumed by the baseline); otherwise\n * the first table match wins, or {@link CUSTOM_PRESET} when no entry\n * matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */',
|
||||
jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last selection wins shared-bundle ties, scoped like the\n * knob chains: a delegation child (header baselines present) ignores\n * seed-carried selections as stale parent history, while a generic fork\n * child keeps them alongside its seed-carried knobs; otherwise the first\n * table match wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'resolve(name: string): PresetSpec',
|
||||
|
||||
@@ -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: 2088574df7145b72c130bdc4f8f841517afb004b
|
||||
README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0
|
||||
README.md: ca6ee67ab0cdb645bba135b3ca4f542844306857
|
||||
README.zh.md: a0c43235de97872ef734f0bef3cc6ed16930e232
|
||||
|
||||
@@ -19,7 +19,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
|
||||
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
|
||||
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
|
||||
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
|
||||
- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: with an inherited `sandboxMode` 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 (throws on foreign values — a durable boundary); without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so seed-carried switches remain the replayed inherited truth. The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
|
||||
|
||||
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。
|
||||
- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。
|
||||
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
|
||||
- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:当存在继承的 `sandboxMode` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界);没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的切换仍是回放所得的继承事实。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
|
||||
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。
|
||||
|
||||
@@ -56,13 +56,17 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo
|
||||
|
||||
/**
|
||||
* The session's complete sandbox-mode OVERRIDE chain — the one home every
|
||||
* consumer (the policy service, the permission presets) resolves through:
|
||||
* the fold of the session's OWN switches (events past the seed boundary — a
|
||||
* fork seed's stale parent switch is subsumed by the delegation baseline
|
||||
* captured after it), else the header's inherited baseline. Never the
|
||||
* deployment default. The durable baseline is validated UNCONDITIONALLY — a
|
||||
* corrupt or foreign header must fail loud on every read, not only when no
|
||||
* own switch happens to shadow it.
|
||||
* consumer (the policy service, the permission presets) resolves through.
|
||||
* With a header baseline (a delegation child), the fold covers only the
|
||||
* session's OWN switches past the seed boundary — the baseline was captured
|
||||
* from the parent's FULL log at delegation, so any seed-carried switch is
|
||||
* already subsumed by it, stale or not. Without a baseline (a top-level
|
||||
* session, or a generic `SessionStore.fork` child that captured no policy
|
||||
* meta), the fold covers the whole log: seeded switches ARE the replayed
|
||||
* inherited truth, and slicing them away would silently widen the child to
|
||||
* the deployment default. Never the deployment default itself. The durable
|
||||
* baseline is validated UNCONDITIONALLY — a corrupt or foreign header must
|
||||
* fail loud on every read, not only when no own switch happens to shadow it.
|
||||
* @param session - the session whose override chain to resolve.
|
||||
* @returns the effective override, or `undefined` for a session following
|
||||
* the deployment default.
|
||||
@@ -70,11 +74,12 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo
|
||||
*/
|
||||
export function sandboxOverrideOf(session: Session): SandboxMode | undefined {
|
||||
const baseline = session.header.sandboxMode
|
||||
if (baseline !== undefined && !SANDBOX_MODES.includes(baseline as SandboxMode)) {
|
||||
if (baseline === undefined) return effectiveSandboxMode(session.events)
|
||||
if (!SANDBOX_MODES.includes(baseline as SandboxMode)) {
|
||||
throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`)
|
||||
}
|
||||
const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0))
|
||||
return own ?? baseline as SandboxMode | undefined
|
||||
return own ?? baseline as SandboxMode
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,4 +205,17 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => {
|
||||
|
||||
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/)
|
||||
})
|
||||
|
||||
it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
// The public fork path sets seedLength but captures no delegation
|
||||
// baseline; the seed boundary must not discard the replayed policy state
|
||||
// it exists to subsume — with nothing to subsume it, seeded switches ARE
|
||||
// the child's inherited truth.
|
||||
const child = inheritedSession('sess-generic-fork', { seedLength: 1 })
|
||||
setSandboxMode(child, 'read-only')
|
||||
|
||||
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,10 +143,11 @@ export class PermissionService extends Service {
|
||||
* 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 OWN selection wins shared-bundle ties (a seed-carried
|
||||
* selection is stale parent history, subsumed by the baseline); otherwise
|
||||
* the first table match wins, or {@link CUSTOM_PRESET} when no entry
|
||||
* matches.
|
||||
* 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.
|
||||
* @returns the effective preset name, or `custom` when nothing matches.
|
||||
*/
|
||||
@@ -154,7 +155,8 @@ export class PermissionService extends Service {
|
||||
const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode
|
||||
const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask'
|
||||
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
|
||||
const folded = effectivePermissionPreset(session.events.slice(session.header.seedLength ?? 0))
|
||||
const delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined
|
||||
const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events)
|
||||
if (folded !== undefined) {
|
||||
const spec = this.presets[folded]
|
||||
if (spec !== undefined && matches(spec)) return folded
|
||||
|
||||
@@ -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: f6eaf049a46bc2faa7e0b12809d3d8c63d7792d6
|
||||
README.zh.md: a286d13eb7c5a3e7893a8269c6d50175eff47046
|
||||
README.md: 978a397f3f48b4e20461c2486e0611313526abf9
|
||||
README.zh.md: 96bbb13c0fbe555faa3dcca44caf31227c505a41
|
||||
|
||||
@@ -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: 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)).
|
||||
`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)).
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -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 消费)解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 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)。
|
||||
|
||||
|
||||
@@ -141,12 +141,16 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv
|
||||
/**
|
||||
* The session's complete approval-policy OVERRIDE chain — the one home every
|
||||
* consumer (this service's policy tier, the permission presets) resolves
|
||||
* through: the fold of the session's OWN switches (events past the seed
|
||||
* boundary — a fork seed's stale parent switch is subsumed by the delegation
|
||||
* baseline captured after it), else the header's inherited baseline. Never
|
||||
* the configured default. The durable baseline is validated UNCONDITIONALLY —
|
||||
* a corrupt or foreign header must fail loud on every read, not only when no
|
||||
* own switch happens to shadow it.
|
||||
* through. With a header baseline (a delegation child), the fold covers only
|
||||
* the session's OWN switches past the seed boundary — the baseline was
|
||||
* captured from the parent's FULL log at delegation, so any seed-carried
|
||||
* switch is already subsumed by it. Without a baseline (a top-level session,
|
||||
* or a generic `SessionStore.fork` child that captured no policy meta), the
|
||||
* fold covers the whole log: seeded switches ARE the replayed inherited
|
||||
* truth, and slicing them away would silently drop a forked `'never'`. Never
|
||||
* the configured default itself. The durable baseline is validated
|
||||
* UNCONDITIONALLY — a corrupt or foreign header must fail loud on every
|
||||
* read, not only when no own switch happens to shadow it.
|
||||
* @param session - the session whose override chain to resolve.
|
||||
* @returns the effective override, or `undefined` for a session following
|
||||
* the configured default.
|
||||
@@ -154,11 +158,12 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv
|
||||
*/
|
||||
export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined {
|
||||
const baseline = session.header.approvalPolicy
|
||||
if (baseline !== undefined && !APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
|
||||
if (baseline === undefined) return effectiveApprovalPolicy(session.events)
|
||||
if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
|
||||
throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`)
|
||||
}
|
||||
const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0))
|
||||
return own ?? baseline as ApprovalPolicy | undefined
|
||||
return own ?? baseline as ApprovalPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -644,4 +644,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => {
|
||||
|
||||
expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/)
|
||||
})
|
||||
|
||||
it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => {
|
||||
const ctx = await mounted()
|
||||
// The public fork path sets seedLength but captures no delegation
|
||||
// baseline; with nothing to subsume them, seeded switches ARE the
|
||||
// child's inherited truth — slicing would silently drop a forked 'never'.
|
||||
const child = inheritedSession('sess-appr-generic-fork', { seedLength: 1 })
|
||||
setApprovalPolicy(child, 'never')
|
||||
|
||||
expect(ctx.approval.overrideOf(child)).toBe('never')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user