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:
@@ -61,7 +61,7 @@ describe('ACP machine permission policy', () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const request = await ownedRequest()
|
||||
const foreign = {
|
||||
session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], header: { version: 0, id: request.agent.session.id, createdAt: 0 }, append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') }))
|
||||
.resolves.toBe('unavailable')
|
||||
|
||||
@@ -152,11 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'overrideOf(session: Session): ApprovalPolicy | undefined',
|
||||
jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the fold alone, never the\n * configured default. The read half of delegation inheritance: the subagent\n * driver captures this synchronously at delegation, so a parent switch\n * racing the child\'s asynchronous creation belongs to the parent\'s future,\n * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched policy, or `undefined` for a never-switched session.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'stampOverride(child: Session, policy: ApprovalPolicy): void',
|
||||
jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a `\'never\'`\n * (headless/CI) parent must not mint children that fall back to a prompting\n * default. A child whose log (e.g. a fork seed) already folds to the policy\n * is left untouched. Callers must append inside an open child turn — a bare\n * between-turn event is crash-tail garbage on reload.\n * @param child - the child session the override is appended to.\n * @param policy - the captured {@link overrideOf} value to stamp.\n */',
|
||||
jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the override chain alone, never\n * the configured default: the fold of the session\'s OWN switches (events\n * past the seed boundary — a fork seed\'s stale parent switch is subsumed by\n * the baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so a `\'never\'` (headless/CI) parent cannot\n * mint children that fall back to a prompting default, at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n * @throws when the durable header baseline is outside the closed policy\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -496,11 +492,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'overrideOf(session: Session): SandboxMode | undefined',
|
||||
jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the fold alone, never the deployment\n * default. The read half of delegation inheritance: the subagent driver\n * captures this synchronously at delegation, so a parent switch racing the\n * child\'s asynchronous creation belongs to the parent\'s future, not to the\n * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched mode, or `undefined` for a never-switched session.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'stampOverride(child: Session, mode: SandboxMode): void',
|
||||
jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a child agent runs\n * under the policy its delegating parent was switched to, not under the\n * (possibly wider) deployment default. A child whose log (e.g. a fork seed)\n * already folds to the mode is left untouched. Callers must append inside\n * an open child turn — a bare between-turn event is crash-tail garbage on\n * reload.\n * @param child - the child session the override is appended to.\n * @param mode - the captured {@link overrideOf} value to stamp.\n */',
|
||||
jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the override chain alone, never the\n * deployment default: the fold of the session\'s OWN switches (events past\n * the seed boundary — a fork seed\'s stale parent switch is subsumed by the\n * baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so the chain collapses one level per\n * delegation and a tightened parent binds children at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n * @throws when the durable header baseline is outside the closed mode\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1514,7 +1506,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreateAgentOptions',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateGoalRequest',
|
||||
@@ -1522,7 +1514,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'DiffCallView',
|
||||
@@ -2002,7 +1994,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionHeader',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionId',
|
||||
|
||||
@@ -48,9 +48,9 @@ export interface CreateAgentOptions {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
|
||||
* recursion budget. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
|
||||
* fork lineage, the `seedLength` seed boundary, the `delegationDepth`
|
||||
* recursion budget, and the inherited `sandboxMode`/`approvalPolicy`
|
||||
* delegation baselines. Mirrors the corresponding fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it). This is durable session data,
|
||||
@@ -62,6 +62,8 @@ export interface CreateAgentOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
readonly sandboxMode?: string
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
|
||||
@@ -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: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c
|
||||
README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb
|
||||
README.md: d1f13669c224ea9fe70a08e6ecacd15118bfb632
|
||||
README.zh.md: ef9dd562a2c849f933ed8dafbfa65c92a3e6bcad
|
||||
|
||||
@@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
|
||||
@@ -44,7 +44,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
@@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### 公共 API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。
|
||||
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。
|
||||
@@ -44,7 +44,7 @@
|
||||
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
|
||||
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
|
||||
- `session.seq`、`session.id`:当前序号和只读类型化身份。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
|
||||
### 无损 JSON 工具
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
### 元数据类型(`types.ts`)
|
||||
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
|
||||
### 扩展点
|
||||
|
||||
|
||||
@@ -151,6 +151,14 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
// Neutral strings only: the owning policy packages validate the values
|
||||
// against their closed vocabularies on read (durable boundary).
|
||||
if (record.sandboxMode !== undefined && typeof record.sandboxMode !== 'string') {
|
||||
throw new Error('session header sandboxMode must be a string')
|
||||
}
|
||||
if (record.approvalPolicy !== undefined && typeof record.approvalPolicy !== 'string') {
|
||||
throw new Error('session header approvalPolicy must be a string')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -680,6 +688,8 @@ export class SessionStore extends Service {
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
...meta?.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode },
|
||||
...meta?.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,24 @@ export interface SessionHeader {
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
/**
|
||||
* The sandbox-mode override inherited from the delegating parent at
|
||||
* creation (the delegation-inheritance baseline). A neutral string here:
|
||||
* the policy owner (`dsh-sandbox-policy`) validates it against its closed
|
||||
* vocabulary on every read, this being a durable boundary. Absent for
|
||||
* top-level sessions and for children of unswitched parents, which keep
|
||||
* following the LIVE deployment default. Header-carried (the
|
||||
* `delegationDepth` precedent) so the baseline is durable from the creation
|
||||
* moment — no first-turn event survives every crash window, because an
|
||||
* idle injection can persist a complete turn before any prompt turn opens.
|
||||
*/
|
||||
readonly sandboxMode?: string
|
||||
/**
|
||||
* The approval-policy override inherited from the delegating parent at
|
||||
* creation. Same contract as {@link SessionHeader.sandboxMode}; validated
|
||||
* by `dsh-user-approval` on read.
|
||||
*/
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +91,8 @@ export interface CreateSessionOptions {
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
readonly sandboxMode?: string
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@ describe('ToolRegistry', () => {
|
||||
*/
|
||||
function fakeAgent(): Agent {
|
||||
return {
|
||||
session: { events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import * as nodePty from 'node-pty'
|
||||
import type { IPtyForkOptions } from 'node-pty'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: the `ctx.sandboxPolicy` Context merge and the `sandbox/mode`
|
||||
// SessionEventMap merge; the service itself arrives via `inject`.
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
@@ -47,7 +49,7 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (session !== owner.session || event.type !== 'sandbox/mode') return
|
||||
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
|
||||
const currentMode = state.sandboxPolicy.overrideOf(session) ?? state.sandboxPolicy.defaultMode
|
||||
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
|
||||
throw new Error(
|
||||
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
|
||||
@@ -76,7 +78,7 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
|
||||
const mode: SandboxMode = ctx.sandboxPolicy.overrideOf(spec.owner.session) ?? ctx.sandboxPolicy.defaultMode
|
||||
if (mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, {
|
||||
mode: mode,
|
||||
|
||||
@@ -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: 3e6d8082766d718b0cfcb94369f48a283713fcd2
|
||||
README.zh.md: 2f84bd4971ddbe0f15e78ef7f5f069350ff43f1f
|
||||
README.md: 733a14141a8a67728b026c7a19d18266b61cbf6a
|
||||
README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5
|
||||
|
||||
@@ -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)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
- `ctx.sandboxPolicy.overrideOf(session)` — 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)).
|
||||
- `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)`/`ctx.sandboxPolicy.stampOverride(child, mode)`:委派继承的两半:仅折叠本身(绝不包含部署默认值),以及通过 `setSandboxMode` 写入捕获的覆盖项,子 agent 已折叠出该值时跳过。进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使发起委派的父级收紧后的模式约束其子 agent(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
- `ctx.sandboxPolicy.overrideOf(session)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
|
||||
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts'
|
||||
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
|
||||
@@ -100,38 +100,35 @@ export class SandboxPolicyService extends Service {
|
||||
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
|
||||
const { session } = request
|
||||
return {
|
||||
mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode,
|
||||
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
|
||||
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's sandbox-mode OVERRIDE — the fold alone, never the deployment
|
||||
* 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 mode, or `undefined` for a never-switched session.
|
||||
* A session's sandbox-mode OVERRIDE — the override chain alone, never the
|
||||
* deployment 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 the chain collapses one level per
|
||||
* delegation and a tightened parent binds children 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 deployment default.
|
||||
* @throws when the durable header baseline is outside the closed mode
|
||||
* vocabulary (a corrupt or foreign log; durable-boundary validation).
|
||||
*/
|
||||
overrideOf(session: Session): SandboxMode | undefined {
|
||||
return effectiveSandboxMode(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a captured override onto a child session through the canonical
|
||||
* write path — the write half of delegation inheritance: a child agent runs
|
||||
* under the policy its delegating parent was switched to, not under the
|
||||
* (possibly wider) deployment default. A child whose log (e.g. a fork seed)
|
||||
* already folds to the mode 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 mode - the captured {@link overrideOf} value to stamp.
|
||||
*/
|
||||
stampOverride(child: Session, mode: SandboxMode): void {
|
||||
if (effectiveSandboxMode(child.events) === mode) return
|
||||
setSandboxMode(child, mode)
|
||||
const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0))
|
||||
if (own !== undefined) return own
|
||||
const baseline = session.header.sandboxMode
|
||||
if (baseline === undefined) return undefined
|
||||
if (!SANDBOX_MODES.includes(baseline as SandboxMode)) {
|
||||
throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`)
|
||||
}
|
||||
return baseline as SandboxMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,41 +143,56 @@ describe('the sandbox/mode session kit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('delegation inheritance (overrideOf + stampOverride)', () => {
|
||||
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
|
||||
describe('delegation inheritance (overrideOf over the header baseline)', () => {
|
||||
/** A session whose header carries the delegation-inheritance baseline. */
|
||||
function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session {
|
||||
const sessionId = SessionId(id)
|
||||
return new Session(sessionId, undefined, {
|
||||
version: 0,
|
||||
id: sessionId,
|
||||
createdAt: 0,
|
||||
...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode },
|
||||
...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
})
|
||||
}
|
||||
|
||||
it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => {
|
||||
it('overrideOf folds the session log and never falls back to the deployment default', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
const parent = session('sess-inherit-parent')
|
||||
setSandboxMode(parent, 'workspace-write')
|
||||
setSandboxMode(parent, 'read-only')
|
||||
|
||||
expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only')
|
||||
// undefined, NOT the deployment default — a child stamped with the
|
||||
// undefined, NOT the deployment default — a child whose header froze the
|
||||
// default would stop following the LIVE default across resumes.
|
||||
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stampOverride appends the captured mode through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
const child = session('sess-inherit-child')
|
||||
it('overrideOf reads the header baseline when the log has no own switch', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' })
|
||||
|
||||
ctx.sandboxPolicy.stampOverride(child, 'read-only')
|
||||
|
||||
const stamped = modeEvents(child)
|
||||
expect(stamped).toHaveLength(1)
|
||||
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
|
||||
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
|
||||
// resolve() consumes the same chain, so enforcement sees the baseline.
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only')
|
||||
})
|
||||
|
||||
it('stampOverride skips a child already folding to the mode (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({ mode: 'workspace-write' })
|
||||
// The fork seed carried the parent's OLD workspace-write switch (one
|
||||
// event, so seedLength 1); the delegation-time baseline is read-only.
|
||||
const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 })
|
||||
setSandboxMode(child, 'workspace-write')
|
||||
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
|
||||
// A switch the child makes ITSELF (after the seed boundary) outranks it.
|
||||
setSandboxMode(child, 'danger-full-access')
|
||||
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => {
|
||||
const ctx = await mounted()
|
||||
const child = session('sess-inherit-dedup-child')
|
||||
// A fork seed can already carry the parent's switch; stamping again would
|
||||
// append a redundant event on every delegation.
|
||||
setSandboxMode(child, 'read-only')
|
||||
const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' })
|
||||
|
||||
ctx.sandboxPolicy.stampOverride(child, 'read-only')
|
||||
|
||||
expect(modeEvents(child)).toHaveLength(1)
|
||||
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d
|
||||
README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1
|
||||
README.md: 75540b957230676850a7abaf7a58877ff7dcbcc5
|
||||
README.zh.md: d82d5f8d0ef263aa3506ed56b7a2b0a83a1cb99d
|
||||
|
||||
@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
session.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
|
||||
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
|
||||
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
|
||||
|
||||
@@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses
|
||||
session.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
|
||||
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
|
||||
- 存储记录是原样 `SessionEvent` JSON,或仅在 `packChunks` 下写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
|
||||
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
|
||||
- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface HeaderLine {
|
||||
parentSession?: SessionId
|
||||
seedLength?: number
|
||||
delegationDepth: number
|
||||
sandboxMode?: string
|
||||
approvalPolicy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,8 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
|
||||
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
delegationDepth: header.delegationDepth ?? 0,
|
||||
...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {},
|
||||
...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +76,8 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
|
||||
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
|
||||
delegationDepth: line.delegationDepth,
|
||||
...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {},
|
||||
...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +96,10 @@ function isHeaderLine(value: unknown): value is HeaderLine {
|
||||
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
|
||||
&& (value as { delegationDepth: number }).delegationDepth >= 0
|
||||
&& !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
|
||||
&& ((value as { sandboxMode?: unknown }).sandboxMode === undefined
|
||||
|| typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string')
|
||||
&& ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined
|
||||
|| typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -301,15 +301,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
created_at = excluded.created_at,
|
||||
cwd = excluded.cwd,
|
||||
parent_session = excluded.parent_session,
|
||||
seed_length = excluded.seed_length,
|
||||
delegation_depth = excluded.delegation_depth
|
||||
delegation_depth = excluded.delegation_depth,
|
||||
sandbox_mode = excluded.sandbox_mode,
|
||||
approval_policy = excluded.approval_policy
|
||||
`).run(
|
||||
meta.id,
|
||||
meta.version,
|
||||
@@ -319,6 +321,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
meta.seedLength ?? null,
|
||||
meta.delegationDepth ?? null,
|
||||
randomUUID(),
|
||||
meta.sandboxMode ?? null,
|
||||
meta.approvalPolicy ?? null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 10
|
||||
export const SCHEMA_VERSION = 11
|
||||
|
||||
/** SQLite application id protecting unrelated databases from persistence writes. */
|
||||
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
|
||||
@@ -41,6 +41,10 @@ export interface SessionRow {
|
||||
/** Monotonic log-change token incremented in each mutating transaction. */
|
||||
revision: number
|
||||
delegation_depth: number | null
|
||||
/** The inherited sandbox-mode delegation baseline, or NULL. */
|
||||
sandbox_mode: string | null
|
||||
/** The inherited approval-policy delegation baseline, or NULL. */
|
||||
approval_policy: string | null
|
||||
}
|
||||
|
||||
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
|
||||
@@ -124,7 +128,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
seed_length INTEGER,
|
||||
delegation_depth INTEGER,
|
||||
incarnation TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
revision INTEGER NOT NULL,
|
||||
sandbox_mode TEXT,
|
||||
approval_policy TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
@@ -181,6 +187,8 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
|
||||
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
|
||||
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
|
||||
...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {},
|
||||
...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ describe('rowToMeta', () => {
|
||||
incarnation: 'fractional',
|
||||
revision: 1,
|
||||
delegation_depth: null,
|
||||
sandbox_mode: null,
|
||||
approval_policy: null,
|
||||
})).toThrow('stored session createdAt must be a non-negative safe integer')
|
||||
})
|
||||
})
|
||||
@@ -606,7 +608,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(10)
|
||||
expect(SCHEMA_VERSION).toBe(11)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
|
||||
@@ -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: 31b917f7edc08182c72b1e4c0ed403a155dc477f
|
||||
README.zh.md: 81cce8f804f5d6cb4a4915f3aecca278dd357db1
|
||||
README.md: 6563fbda00c8fc9a22be5f34ffb327d5876e1c05
|
||||
README.zh.md: f0cd20b3a5c40cb76141ff1fb0b6cd1f5f6b59f5
|
||||
|
||||
@@ -18,7 +18,7 @@ The driver follows this sequence:
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and a one-shot PREPENDED `agent/prompt-submit` listener stamps the captured values through `stampOverride` (both services consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request; prepending puts it before veto-capable listeners, so a denying UserPromptSubmit hook cannot close the first turn without the stamp; its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing. Only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each capture folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——再由一个一次性、前置安装的 `agent/prompt-submit` 监听器通过 `stampOverride` 盖章写入捕获值(两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派)。锚定在子 agent 的第一个轮次内,使盖章事件被包围在轮次内(具备持久性)并先于第一次请求;前置安装使其位于具备否决能力的监听器之前,因此作出拒绝的 UserPromptSubmit 钩子无法在未盖章的情况下关闭第一个轮次;盖章事件在日志中位于 fork 初始内容携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析陈旧初始内容的时序。只复制覆盖链,因此未切换过的父 agent 不盖任何章,子 agent 继续跟随实时部署默认值。嵌套按构造即可组合:每次捕获折叠的都是发起委派的会话已经盖过章的日志(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值;`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
|
||||
@@ -100,16 +100,17 @@ export async function startInProcessRun(
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// Policy inheritance, read half: capture the parent's sandbox/approval
|
||||
// OVERRIDES synchronously, before the first await — the delegation moment
|
||||
// is the semantic snapshot point, and a parent switch racing the child's
|
||||
// Policy inheritance: capture the parent's sandbox/approval OVERRIDES
|
||||
// synchronously, before the first await — the delegation moment is the
|
||||
// semantic snapshot point, and a parent switch racing the child's
|
||||
// asynchronous creation must belong to the parent's future, not the child.
|
||||
// Both services are consumed opportunistically — without them, delegation
|
||||
// stays policy-free.
|
||||
const sandboxPolicy = parent.ctx.get('sandboxPolicy')
|
||||
const approval = parent.ctx.get('approval')
|
||||
const inheritedMode = sandboxPolicy?.overrideOf(parent.session)
|
||||
const inheritedPolicy = approval?.overrideOf(parent.session)
|
||||
// The captured values ride the child's creation meta into its immutable
|
||||
// header, so the baseline is durable from the moment the session exists —
|
||||
// no first-turn event could survive every crash window (an idle injection
|
||||
// can persist a complete turn before any prompt turn opens). Both services
|
||||
// are consumed opportunistically — without them, delegation is policy-free.
|
||||
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
|
||||
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
@@ -120,23 +121,6 @@ export async function startInProcessRun(
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
// Write half: stamp the captured overrides once, anchored inside the
|
||||
// child's FIRST turn (prompt-submit runs after turn/start, before prompt
|
||||
// assembly) — a bare between-turn append would be crash-tail garbage on
|
||||
// reload, and stamping here also orders the override after any stale
|
||||
// switch a fork seed carried, so the ordinary last-event-wins fold
|
||||
// resolves it. PREPENDED so a veto-capable listener (a denying
|
||||
// UserPromptSubmit hook) cannot close the first turn without the stamp —
|
||||
// the stamp must be durable even for a blocked first prompt. One-shot:
|
||||
// later turns must not re-stamp over a switch the child made itself.
|
||||
if (inheritedMode !== undefined || inheritedPolicy !== undefined) {
|
||||
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
|
||||
disposeInherit()
|
||||
if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode)
|
||||
if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy)
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
}
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
@@ -148,6 +132,8 @@ export async function startInProcessRun(
|
||||
// Durable: the recursion budget must survive persistence and resume.
|
||||
delegationDepth: childDepth,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {},
|
||||
...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {},
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
|
||||
@@ -198,16 +198,13 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
|
||||
expect(result.stopReason).toBe('completed')
|
||||
|
||||
// The stamped override is the child's OWN durable, turn-enclosed record:
|
||||
// after turn/start, before the first model request snapshot.
|
||||
const events = child.session.events
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const mode = events.findIndex(e => e.type === 'sandbox/mode')
|
||||
const policy = events.findIndex(e => e.type === 'approval/policy')
|
||||
const header = events.findIndex(e => e.type === 'request/header')
|
||||
expect(mode).toBeGreaterThan(turnStart)
|
||||
expect(policy).toBeGreaterThan(turnStart)
|
||||
expect(header).toBeGreaterThan(mode)
|
||||
// The inherited baseline is part of the child's IMMUTABLE header —
|
||||
// durable from the creation moment, with no first-turn timing window
|
||||
// (a crash after any persisted turn still resumes with the baseline).
|
||||
expect(child.session.header.sandboxMode).toBe('read-only')
|
||||
expect(child.session.header.approvalPolicy).toBe('never')
|
||||
// The log stays free of stamped events: the header is the one home.
|
||||
expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 })
|
||||
// What the enforcing families resolve for the child, end to end.
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
|
||||
// Inheritance reads the parent log, never writes it.
|
||||
@@ -216,6 +213,33 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the baseline is durable BEFORE any child turn exists (the injection-turn crash window)', async () => {
|
||||
// The review scenario: a SessionStart-style idle injection can persist a
|
||||
// complete turn before the first prompt turn opens. The baseline must
|
||||
// already be durable then — it is, because it rides the creation-time
|
||||
// header, not a first-turn event.
|
||||
const script: Script = []
|
||||
const { parent } = await setupWalled(script)
|
||||
script.push(
|
||||
() => {
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
return textResponse('staged')
|
||||
},
|
||||
textResponse('child done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
const child = run.localAgent as Agent
|
||||
// Assert on the HEADER immediately after publication — before the child's
|
||||
// first turn has run (run.result not yet awaited). An idle injection
|
||||
// persisting a turn now would carry the baseline with it.
|
||||
expect(child.session.header.sandboxMode).toBe('read-only')
|
||||
await run.result
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => {
|
||||
const script: Script = []
|
||||
const captured: Agent[] = []
|
||||
@@ -373,10 +397,11 @@ describe('inheritance survives prompt vetoes', () => {
|
||||
await run.result
|
||||
const child = run.localAgent as Agent
|
||||
|
||||
// The veto closed the first turn promptless, but the stamp is inside that
|
||||
// turn regardless — a later resume must not fall back to the deployment
|
||||
// default just because the first prompt was blocked.
|
||||
expect(overrideEvents(child)).toEqual({ sandbox: 1, approval: 0 })
|
||||
// The veto closed the first turn promptless, but the baseline rides the
|
||||
// creation-time header — no listener ordering can starve it, and a later
|
||||
// resume must not fall back to the deployment default just because the
|
||||
// first prompt was blocked.
|
||||
expect(child.session.header.sandboxMode).toBe('read-only')
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
|
||||
|
||||
await run.dispose()
|
||||
@@ -399,7 +424,9 @@ describe('inheritance guards (must hold before AND after the fix)', () => {
|
||||
|
||||
// workspace-write (the deployment default) really allowed the write…
|
||||
expect(await readFile(allowed, 'utf8')).toBe('fine')
|
||||
// …and nothing froze that default into the child log.
|
||||
// …and nothing froze that default into the child header or log.
|
||||
expect(child.session.header.sandboxMode).toBeUndefined()
|
||||
expect(child.session.header.approvalPolicy).toBeUndefined()
|
||||
expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 })
|
||||
|
||||
await run.dispose()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)`/`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 Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user