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:
kingwl
2026-07-26 23:06:15 +08:00
parent b02d3e4c03
commit 99f5fab7bc
17 changed files with 78 additions and 41 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: 2088574df7145b72c130bdc4f8f841517afb004b
README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0
README.md: ca6ee67ab0cdb645bba135b3ca4f542844306857
README.zh.md: a0c43235de97872ef734f0bef3cc6ed16930e232

View File

@@ -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.

View File

@@ -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 与其配套组件拥有周围的存储与轮次封闭规则。

View File

@@ -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
}
/**

View File

@@ -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')
})
})