From cfceb8452b4f988d6501b945b419ab65f11ab7b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:31:17 +0800 Subject: [PATCH] subagent: seed inherited policy events at creation The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch. Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults. Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly. This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 51 +- .../persistence.i18n.yaml | 4 +- docs/core-data-structures/persistence.md | 22 +- docs/core-data-structures/persistence.zh.md | 22 +- docs/persistence-catalog.md | 59 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../parent-override/child.expected.jsonl | 49 +- .../tests/subagent-inheritance.snapshot.ts | 29 +- packages/acp/acp/tests/approval.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/agent/src/index.ts | 24 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 +- packages/core/session/README.zh.md | 6 +- packages/core/session/src/index.ts | 10 - packages/core/session/src/types.ts | 22 +- packages/core/session/tests/session.spec.ts | 4 - packages/core/tools/tests/tools.spec.ts | 2 +- packages/pty/pty-local/src/index.ts | 8 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 7 +- packages/sandbox/sandbox-policy/README.zh.md | 7 +- packages/sandbox/sandbox-policy/src/index.ts | 30 +- .../sandbox-policy/src/session-mode.ts | 84 +-- .../sandbox-policy/tests/policy.spec.ts | 99 +--- .../README.i18n.yaml | 6 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 10 - .../session-persistence-sqlite/src/index.ts | 10 +- .../session-persistence-sqlite/src/schema.ts | 12 +- .../tests/sqlite.spec.ts | 4 +- .../session-persistence/src/coordinator.ts | 28 - .../session-persistence/tests/contract.ts | 46 -- .../tests/coordinator-contract.ts | 69 --- .../session-query-sqlite/src/index.ts | 64 +-- .../session-query-sqlite/src/schema.ts | 6 +- .../session-query-sqlite/tests/sqlite.spec.ts | 34 -- .../session-query/src/sources.ts | 2 - .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 - .../subagent/subagent-inprocess/README.zh.md | 2 - .../subagent/subagent-inprocess/src/index.ts | 33 +- .../tests/inheritance.spec.ts | 517 ++++-------------- packages/ui/permission/README.i18n.yaml | 4 +- packages/ui/permission/README.md | 2 +- packages/ui/permission/README.zh.md | 4 +- packages/ui/permission/src/index.ts | 38 +- .../ui/permission/tests/permission.spec.ts | 57 +- packages/ui/user-approval/README.i18n.yaml | 6 +- packages/ui/user-approval/README.md | 4 +- packages/ui/user-approval/README.zh.md | 4 +- packages/ui/user-approval/src/index.ts | 105 ++-- .../ui/user-approval/tests/approval.spec.ts | 134 +---- 55 files changed, 415 insertions(+), 1371 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cbfe7539b8..f145546f2a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1971,7 +1971,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:236`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:202`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 626bc5a610..0bf299fd25 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:222`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -244,19 +244,16 @@ Approval service that applies session policy before answerers and logs every ask async request(req: ApprovalRequest): Promise /** - * {@link approvalOverrideOf} surfaced on the service, for consumers that - * reach the seam through `ctx.get('approval')` (the subagent driver's - * delegation capture) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the configured default. + * Read the session override without applying the configured default. + * @param session - session whose log supplies the override. + * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined ``` Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:251`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -817,19 +814,13 @@ Owns the deployment's permission presets and their write path. Requires a confin ```ts cordis-catalog /** - * Resolve the preset matching the effective knob values — the same - * 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 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. + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. * @returns the effective preset name, or `custom` when nothing matches. */ -current(session: Session): string +current(events: readonly SessionEvent[]): string /** * Resolve a preset's knob bundle. @@ -857,7 +848,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) @@ -1000,23 +991,19 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo ```ts cordis-catalog /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's override chain ({@link overrideOf}: own - * post-seed switches, else the inherited header baseline), which outranks - * the deployment default. A session cwd is its workspace-write boundary; - * the configured root is the fallback for agentless calls and sessions - * without a cwd. + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy /** - * {@link sandboxOverrideOf} surfaced on the service, for consumers that - * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's - * delegation capture, pty-local) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the deployment default. + * Read the session override without applying the deployment default. + * @param session - session whose log supplies the override. + * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined ``` @@ -1432,7 +1419,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:702`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 0227432913..ea83035f92 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -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 docs/core-data-structures/persistence.md -persistence.md: 6238182a9d570852bc1ba6e89de90427aa625b99 -persistence.zh.md: 414454dd19131a07876620e9c1e3232e155e9ccf +persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de +persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 6238182a9d..5a660e17d6 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -72,30 +72,12 @@ 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 } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, the inherited `sandboxMode`/`approvalPolicy` delegation baselines, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -116,8 +98,6 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 414454dd19..b5477cfc82 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -72,30 +72,12 @@ 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 } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`、继承的 `sandboxMode`/`approvalPolicy` 委派基线,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -116,8 +98,6 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 84f4f28acc..2b8d936a9c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) ## Events @@ -129,16 +129,19 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The last such OWN - * (post-seed) event is the session's override - * ({@link approvalOverrideOf}); who asked for it is derivable from - * position (an own event after the log's last own `request/header` was a - * runtime switch by the user). + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}). + * `source: 'delegation'` marks an override seeded into a child; an absent + * source is a runtime switch. */ -'approval/policy': { policy: ApprovalPolicy } +'approval/policy': { + policy: ApprovalPolicy + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` -Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -151,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) ### `command/*` @@ -372,7 +375,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -382,16 +385,18 @@ Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/ /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The last such OWN (post-seed) event is - * the session's override ({@link sandboxOverrideOf}); who asked for it is - * derivable from position (an event after the log's last - * `request/header*` was a runtime switch by the user; see the tool - * layer's narrator). + * never in the model transcript. The LAST such event is the session's + * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * an override seeded into a child; an absent source is a runtime switch. */ -'sandbox/mode': { mode: SandboxMode } +'sandbox/mode': { + mode: SandboxMode + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` -Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:39`](../packages/sandbox/sandbox-policy/src/session-mode.ts) +Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/sandbox/sandbox-policy/src/session-mode.ts) ### `session/*` @@ -429,7 +434,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `step/*` @@ -440,7 +445,7 @@ Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -449,7 +454,7 @@ Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) ### `todo/*` @@ -462,7 +467,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) ### `tool/*` @@ -479,7 +484,7 @@ Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -552,7 +557,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) ### `turn/*` @@ -570,7 +575,7 @@ Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -583,7 +588,7 @@ Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) ### `user/*` @@ -601,4 +606,4 @@ Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 8aa8882c11..1d41f4aa7e 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index eefcf2abb1..59a93af0f6 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -1,24 +1,25 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1,"sandboxMode":"read-only"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1} +{"type":"sandbox/mode","seq":0,"time":0,"data":{"mode":"read-only","source":"delegation"}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[2],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index 25c8ba5974..02c96cac15 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -1,15 +1,6 @@ /** - * Keyless assembled-app snapshot for parent-only policy inheritance: the - * deployment default stays WIDE (workspace-write on the shared policy home) - * while the seeded parent session carries a session-scoped `sandbox/mode: - * read-only` override; the Loader-booted headless app resumes it, the parent - * delegates through the real subagent tool, and the child's real `write` - * hits the real `dsh-fs-sandbox` fence. Only the delegation-inheritance - * capture can confine the child here — remove it and the child inherits - * nothing, writes `inherited.txt` successfully under the deployment default, - * and every assertion below fails. This is the assembled-app red/green - * anchor the ACP scenario cannot express (the automation protocol has no - * session-scoped switch). + * Assembled-app regression: a parent-only read-only override is seeded into + * its child log and confines a real write under a wider deployment default. */ import { readFile, readdir, writeFile } from 'node:fs/promises' @@ -35,11 +26,7 @@ const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Delegate the write probe to a subagent.' -/** - * Seed the parent: a completed turn whose ONLY policy fact is a session-scoped - * `sandbox/mode: read-only` switch — the deployment default stays wider, so - * the child's confinement below can come from inheritance alone. - */ +/** Seed a completed parent turn with the only read-only fact in the app. */ async function seedReadOnlyParent(root: string, cwd: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -101,8 +88,14 @@ describe('parent-only override inheritance snapshot', () => { const child = logs.find(content => typeof headerOf(content).parentSession === 'string') if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') - // The inherited baseline is the child's durable header record. - expect(headerOf(child).sandboxMode).toBe('read-only') + const childRecords = child.trimEnd().split('\n').map( + line => JSON.parse(line) as Record, + ) + expect(childRecords[1]).toMatchObject({ + type: 'sandbox/mode', + seq: 0, + data: { mode: 'read-only', source: 'delegation' }, + }) const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd } const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index bacb7b4842..01bcd83249 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -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' }], header: { version: 0, id: request.agent.session.id, createdAt: 0 }, append: () => ({}) }, + session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 372bdb9023..1648bdb5c6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,7 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - jsDoc: '/**\n * {@link approvalOverrideOf} surfaced on the service, for consumers that\n * reach the seam through `ctx.get(\'approval\')` (the subagent driver\'s\n * delegation capture) rather than a value import.\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 */', + jsDoc: '/**\n * Read the session override without applying the configured default.\n * @param session - session whose log supplies the override.\n * @returns the last logged policy, or `undefined` without one.\n */', }, ], }, @@ -411,8 +411,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Owns the deployment\'s permission presets and their write path.', 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 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: 'current(events: readonly SessionEvent[]): string', + jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, { signature: 'resolve(name: string): PresetSpec', @@ -500,11 +500,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', - jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s override chain ({@link overrideOf}: own\n * post-seed switches, else the inherited header baseline), which outranks\n * the deployment default. A session cwd is its workspace-write boundary;\n * the configured root is the fallback for agentless calls and sessions\n * without a cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', + jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', - jsDoc: '/**\n * {@link sandboxOverrideOf} surfaced on the service, for consumers that\n * reach policy through `ctx.get(\'sandboxPolicy\')` (the subagent driver\'s\n * delegation capture, pty-local) rather than a value import.\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 */', + jsDoc: '/**\n * Read the session override without applying the deployment default.\n * @param session - session whose log supplies the override.\n * @returns the last logged mode, or `undefined` without one.\n */', }, ], }, @@ -1593,7 +1593,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 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;\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 };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateGoalRequest', @@ -1601,7 +1601,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 readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\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 };\n}', }, { name: 'DiffCallView', @@ -2109,7 +2109,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 readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\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}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 8b37515d02..49bd2c7ce1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -47,9 +47,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, the `delegationDepth` - * recursion budget, and the inherited `sandboxMode`/`approvalPolicy` - * delegation baselines. Mirrors the corresponding fields of + * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` + * recursion budget. Mirrors the + * `cwd`/`parentSession`/`seedLength`/`delegationDepth` 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, @@ -61,20 +61,14 @@ 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 - * primitive). When present, the factory creates the session with this event - * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the - * in-process FORK subagent backend to seed a child with a balanced - * completed-turn prefix of the parent's log. The prefix MUST be contiguous - * from seq 0, carry only lossless-JSON data, and be balanced (no open - * turn/step, no dangling tool-call), or the session constructor (and the - * dev-mode invariants replay) reject it. The factory passes the raw seed to - * the session's durable validator/snapshot boundary. Absent for a fresh - * (spawn) child. + * Initial session events. A fork starts with a balanced completed-turn + * prefix of the parent's log; creation-time log facts may follow that + * prefix. The complete seed must be contiguous from seq 0, carry only + * lossless-JSON data, and contain no open turn/step or dangling tool call. + * The factory passes it to the session's durable validator/snapshot + * boundary before publication. */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index d8fa783d44..c9bf568a38 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -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 packages/core/session/README.md -README.md: 5168a08d293b71a60f23ed65c434ad6d33a7cdfe -README.zh.md: 236ee2a66efc5742765c062c20a4eadde34dffdb +README.md: 40516d12180de9c30efd40fdffa873da20ddacb3 +README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 5168a08d29..40516d1218 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines. +- `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.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. - `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 log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. @@ -43,7 +43,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`/`sandboxMode`/`approvalPolicy`). 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`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -86,7 +86,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?, 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). +- `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). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 236ee2a66e..43842643a3 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,7 +12,7 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 @@ -43,7 +43,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -86,7 +86,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 544e678c68..e42414503b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -143,14 +143,6 @@ 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) } @@ -776,8 +768,6 @@ 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) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 448961da1d..f5027b2d84 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -64,24 +64,6 @@ 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 } /** @@ -90,7 +72,7 @@ export interface SessionHeader { * store folds into a {@link SessionHeader}. */ export interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial log events supplied at construction (replay, fork, or creation-time facts). */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit @@ -102,8 +84,6 @@ export interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index cd7a5d8768..7152dd5d41 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -977,8 +977,6 @@ describe('Session', () => { { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, - { header: { ...base, sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, - { header: { ...base, approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const { header, error } of cases) { @@ -1230,8 +1228,6 @@ describe('SessionStore', () => { { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, - { meta: { sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, - { meta: { approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 3a6bb13a66..4149a21273 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -725,7 +725,7 @@ describe('ToolRegistry', () => { */ function fakeAgent(): Agent { return { - session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) }, + session: { events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index c133a5bb7a..d768d6bfa8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -9,13 +9,11 @@ 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 { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' 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' @@ -49,7 +47,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 = state.sandboxPolicy.overrideOf(session) ?? state.sandboxPolicy.defaultMode + const currentMode = effectiveSandboxMode(session.events) ?? 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`, @@ -75,7 +73,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 = ctx.sandboxPolicy.overrideOf(spec.owner.session) ?? ctx.sandboxPolicy.defaultMode + const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv return ctx.sandbox.confine(argv, { mode: mode, diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 257d47fa87..c324986a78 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -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 packages/sandbox/sandbox-policy/README.md -README.md: 3e569cebb22cbed9d25642b7208e33e97041323e -README.zh.md: 2d1c30f9d79eada9b38785113a880c4760c7d0f1 +README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd +README.zh.md: abf2d9fb8830fdcaf7f1357b393b434de4a5ad8d diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 3e569cebb2..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -15,18 +15,17 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de ## Surface -- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's override chain (`overrideOf`, below), which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. +- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. -- `effectiveSandboxMode(events)` — the pure fold of a slice of `sandbox/mode` events (the last switch wins, or `undefined`), the building block `sandboxOverrideOf` composes with the seed boundary and the header baseline. +- `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: 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 core execution-enclosure rules. ## The per-session store -A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? override chain ?? deployment default`, where the override chain is `sandboxOverrideOf`'s fold of the session's OWN post-seed switches, else the inherited header baseline — so an override survives restart by replay, a delegation child starts under its parent's captured policy, and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. +A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. ## Model Experience diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2d1c30f9d7..abf2d9fb88 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -15,18 +15,17 @@ ## 表层 -- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话的覆盖链(见下文 `overrideOf`),后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 +- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 -- `effectiveSandboxMode(events)`:对一段 `sandbox/mode` 事件切片的纯折叠(最后一次切换胜出,没有则为 `undefined`),是 `sandboxOverrideOf` 与种子边界和会话头基线进行组合时所用的基础构件。 +- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `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 与其配套组件拥有周围的存储与核心执行封闭规则。 ## 逐会话 store -运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? override chain ?? deployment default`,其中覆盖链(override chain)是 `sandboxOverrideOf` 折叠会话自己在种子之后的切换所得,否则取继承的会话头基线——因此覆盖会通过回放跨重启保留,委派子 agent 会在其父级捕获的策略下启动,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index dd35c303af..1f5ba0bb00 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,9 +19,9 @@ 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 { sandboxOverrideOf } from './session-mode.ts' +import { effectiveSandboxMode } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, sandboxOverrideOf, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -90,36 +90,28 @@ export class SandboxPolicyService extends Service { /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's override chain ({@link overrideOf}: own - * post-seed switches, else the inherited header baseline), which outranks - * the deployment default. A session cwd is its workspace-write boundary; - * the configured root is the fallback for agentless calls and sessions - * without a cwd. + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request - // Resolve the session override FIRST even when an explicit approved mode - // outranks it: the unconditional durable-header validation must hold on - // every resolution — a one-shot grant is not a validation bypass. - const override = session === undefined ? undefined : this.overrideOf(session) return { - mode: request.mode ?? override ?? this.defaultMode, + mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } /** - * {@link sandboxOverrideOf} surfaced on the service, for consumers that - * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's - * delegation capture, pty-local) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the deployment default. + * Read the session override without applying the deployment default. + * @param session - session whose log supplies the override. + * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return sandboxOverrideOf(session) + return effectiveSandboxMode(session.events) } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index d2f018818a..b4cd085859 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -1,19 +1,15 @@ /** - * Per-session sandbox-mode override: the session log as the store, layered - * over the header's delegation baseline. A runtime switch (a UI policy - * control or test scenario) is recorded as one `sandbox/mode` event on the - * session it applies to; `effective = override chain ?? the deployment - * default`, where the override chain ({@link sandboxOverrideOf}) is the fold - * of the session's OWN post-seed switches, else the inherited - * `SessionHeader.sandboxMode` baseline. An override survives restart by - * replay, a delegation child starts under its parent's captured policy, two - * sessions can never see each other's state, and there is no external config - * store. The event is log-only (the `approval/*` precedent): the model - * learns the mode from the boundary markers in the enforcing tools, never - * from the event itself. EXECUTION honors the chain through - * `ctx.sandboxPolicy.resolve()` — it stamps the mode together with the - * calling session's workspace root onto each capability call, - * weakest-precedence beneath an escalation grant. + * Per-session sandbox-mode override: the session log as the store. A runtime + * switch (a UI policy control or test scenario) is recorded as one + * `sandbox/mode` event on the session it applies to; + * `effective = fold(events) ?? the deployment default`, so an override + * survives restart by replay, two sessions can never see each other's state, + * and there is no external config store. The event is log-only (the + * `approval/*` precedent): the model learns the mode from the boundary + * markers in the enforcing tools, never from the event itself. EXECUTION + * honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode + * together with the calling session's workspace root onto each capability + * call, weakest-precedence beneath an escalation grant. * * The override is policy state shared by every enforcing family (bash and * filesystem alike), so it lives here in the policy package rather than in any @@ -30,13 +26,15 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The last such OWN (post-seed) event is - * the session's override ({@link sandboxOverrideOf}); who asked for it is - * derivable from position (an event after the log's last - * `request/header*` was a runtime switch by the user; see the tool - * layer's narrator). + * never in the model transcript. The LAST such event is the session's + * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * an override seeded into a child; an absent source is a runtime switch. */ - 'sandbox/mode': { mode: SandboxMode } + 'sandbox/mode': { + mode: SandboxMode + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' + } } } @@ -44,11 +42,10 @@ declare module '@deepseek-ai/dsh-session' { export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] /** - * The pure fold of a slice of `sandbox/mode` events: the last switch wins, - * or undefined without one. The building block {@link sandboxOverrideOf} - * composes with the seed boundary and the header baseline — consumers - * resolving a SESSION's policy go through that chain, not this raw fold. - * Resume needs no catch-up machinery because replaying the log IS the state. + * The session's sandbox-mode override: the last `sandbox/mode` event in the + * log, or undefined when the session never switched (callers apply the + * deployment default). The pure fold — resume needs no catch-up machinery + * because replaying the log IS the state. * @param events - session events in log order (other event types are skipped). * @returns the mode of the last switch event, or undefined without one. */ @@ -60,41 +57,6 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo return undefined } -/** - * The session's complete sandbox-mode OVERRIDE chain — the one home every - * 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. - * @throws when the header baseline is outside the closed mode vocabulary. - */ -export function sandboxOverrideOf(session: Session): SandboxMode | undefined { - const baseline = session.header.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`) - } - // A boundary past the log would make the own-switch slice empty until the - // log grows past it — a wide baseline would then shadow a REAL later - // tightening. Malformed durable metadata fails loud, never fails open. - const seedLength = session.header.seedLength ?? 0 - if (seedLength > session.events.length) { - throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) - } - const own = effectiveSandboxMode(session.events.slice(seedLength)) - return own ?? baseline as SandboxMode -} - /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index b0362b6592..63ca0cd3d5 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -63,6 +63,8 @@ describe('SandboxPolicyService', () => { mode: 'read-only', workspaceRoot: resolve('/projects/second'), }) + expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() + expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), @@ -142,100 +144,3 @@ describe('the sandbox/mode session kit', () => { expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) }) - -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 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 whose header froze the - // default would stop following the LIVE default across resumes. - expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined() - }) - - 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' }) - - 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('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 = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' }) - - expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) - }) - - it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-inherit-invalid-own', { sandboxMode: 'yolo' }) - // A corrupt or foreign durable record must fail loud on EVERY read — an - // own override must not paper over the malformed header. - setSandboxMode(child, 'read-only') - - 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') - }) - - it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { - const ctx = await mounted() - // A malformed durable seedLength beyond the log would make the own-switch - // slice empty until the log grows past it — a wide baseline would then - // shadow a REAL later tightening. Fail loud at the durable boundary. - const child = inheritedSession('sess-inherit-oob', { sandboxMode: 'danger-full-access', seedLength: 100 }) - setSandboxMode(child, 'read-only') - - expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/) - }) - - it('resolve() validates the durable header even when an explicit approved mode is supplied', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-resolve-invalid', { sandboxMode: 'yolo' }) - - // The explicit one-shot grant must not become a validation bypass: the - // unconditional durable-header contract holds on EVERY resolution. - expect(() => ctx.sandboxPolicy.resolve({ session: child, mode: 'workspace-write' })).toThrow(/sandboxMode/) - }) -}) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index 2cf403339f..f817c87919 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 packages/session-persistence/session-persistence-jsonl/README.md -README.md: 2f68b3d02fecb61042964e69edbb446219a02e25 -README.zh.md: 74a73d1c48ddc2ef0de4679489b8494b7364eee5 +# pnpm run verify-translation-pairing --write +README.md: ab6ecd28f12bd167aeac789d1565705e167d60f4 +README.zh.md: 97d387a04fa4c658217e28619410a49b7e6d4ec0 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 2f68b3d02f..ab6ecd28f1 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -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, 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`). +- 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`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — 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. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index 74a73d1c48..97d387a04f 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`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 文件名。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 1032096943..03e8b15da0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -38,8 +38,6 @@ export interface HeaderLine { parentSession?: SessionId seedLength?: number delegationDepth: number - sandboxMode?: string - approvalPolicy?: string } /** @@ -57,8 +55,6 @@ 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 } : {}, } } @@ -76,8 +72,6 @@ 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 } : {}, } } @@ -96,10 +90,6 @@ 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') ) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 8d98fbdf2d..f771b9e3a7 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -301,17 +301,15 @@ 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, sandbox_mode, approval_policy) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) + 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, - sandbox_mode = excluded.sandbox_mode, - approval_policy = excluded.approval_policy + delegation_depth = excluded.delegation_depth `).run( meta.id, meta.version, @@ -321,8 +319,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.delegationDepth ?? null, randomUUID(), - meta.sandboxMode ?? null, - meta.approvalPolicy ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index f8e3285ba2..754d9d7e63 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -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 = 11 +export const SCHEMA_VERSION = 10 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -41,10 +41,6 @@ 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). */ @@ -128,9 +124,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, delegation_depth INTEGER, incarnation TEXT NOT NULL, - revision INTEGER NOT NULL, - sandbox_mode TEXT, - approval_policy TEXT + revision INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS events ( @@ -187,8 +181,6 @@ 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 } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 6c4db46e9b..82f00519ba 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -171,8 +171,6 @@ 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') }) }) @@ -611,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(11) + expect(SCHEMA_VERSION).toBe(10) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 68c5ab744f..878dddcb42 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -118,32 +118,6 @@ async function settledErrors(promises: Iterable>): Promise { if (tracked.meta.cwd !== session.header.cwd) { throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - assertSamePolicyBaselines(id, tracked.meta, session.header) if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) } @@ -640,7 +613,6 @@ export class PersistenceCoordinator { if (meta.cwd !== session.header.cwd) { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - assertSamePolicyBaselines(session.header.id, meta, session.header) this.assertVersion(meta) assertSupportedEvents(events, session.header.id) if (!seedCoversPrefix(seed, events)) { diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 17d54f4be5..24a76b02cd 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -99,36 +99,6 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - // A delegated child header: the sandbox/approval baselines must - // survive storage verbatim — a resumed child falling back to the - // deployment default would reopen the delegation bypass. - const child: SessionHeader = { - ...meta('s-baseline', '/work'), - delegationDepth: 1, - sandboxMode: 'read-only', - approvalPolicy: 'never', - } - await persistence.create(child) - await persistence.append(child.id, oneTurnLog()) - const loaded = await persistence.load(child.id) - expect(loaded.meta).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - - // A top-level header: absent baselines stay ABSENT (not null/empty) — - // presence is the signal the policy owners branch on. - const top = meta('s-no-baseline', '/work') - await persistence.create(top) - await persistence.append(top.id, oneTurnLog()) - const reloaded = await persistence.load(top.id) - expect('sandboxMode' in reloaded.meta).toBe(false) - expect('approvalPolicy' in reloaded.meta).toBe(false) - } finally { - await dispose() - } - }) - it('rejects a fractional creation timestamp without reserving its session id', async () => { const { persistence, dispose } = await make() try { @@ -307,22 +277,6 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - // The abort observer must not swallow an ordinary success: a signal - // that stays quiet leaves the queued operation's resolution intact. - const m = meta('signal-quiet-inspect', '/work') - await persistence.create(m) - await persistence.append(m.id, oneTurnLog()) - const controller = new AbortController() - await expect(persistence.inspect(m.id, controller.signal)) - .resolves.toMatchObject({ meta: { id: m.id } }) - } finally { - await dispose() - } - }) - it('rejects pre-aborted observation reads with the exact cancellation reason', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 95399effd9..d63cb857a2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -753,75 +753,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // A stored artifact carrying a WIDE baseline. A same-id live session - // claiming a NARROW baseline must be rejected: adoption retains the - // stored header, so accepting the pair would let the session append - // under read-only now but resume under danger-full-access later. - await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' }) - await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('baseline-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, sandboxMode: 'read-only' }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session with a DIFFERENT seed boundary cannot adopt a stored prefix when a baseline exists', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Same wide baseline both sides, but the stored header says event 0 is - // seed-carried (seedLength 1) while the live header says it is the - // session's OWN (seedLength 0). overrideOf() resolves policy through - // that boundary: a read-only switch at event 0 tightens the live - // session, yet a restart resumes under the stored header and the wide - // baseline silently returns. The boundary is part of the policy - // identity whenever a baseline exists. - await ctx.sessionPersistence.create({ - ...meta('seed-boundary-conflict', WORK), - sandboxMode: 'danger-full-access', - seedLength: 1, - }) - await ctx.sessionPersistence.append(SessionId('seed-boundary-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('seed-boundary-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, sandboxMode: 'danger-full-access' }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('an approval-only baseline also pins the seed boundary (the other baseline arm)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // The boundary guard triggers off EITHER baseline: a stored header - // with only approvalPolicy (no sandboxMode, no seedLength) must still - // reject a live twin whose boundary differs. - await ctx.sessionPersistence.create({ ...meta('approval-boundary-conflict', WORK), approvalPolicy: 'never' }) - await ctx.sessionPersistence.append(SessionId('approval-boundary-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('approval-boundary-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, approvalPolicy: 'never', seedLength: 2 }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index ce89921ade..0c073ccea5 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -146,8 +146,6 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null - sandbox_mode: string | null - approval_policy: string | null } interface SearchRow extends SessionHeaderRow { @@ -526,23 +524,6 @@ export class SessionQuerySqlite extends SessionQueryService { } } - /** The shared header column bindings both session tables lead with. */ - private static _headerBindings( - header: SessionHeader, - ): [string, number, number, string | null, string | null, number | null, number | null, string | null, string | null] { - return [ - header.id, - header.version, - header.createdAt, - header.cwd ?? null, - header.parentSession ?? null, - header.seedLength ?? null, - header.delegationDepth ?? null, - header.sandboxMode ?? null, - header.approvalPolicy ?? null, - ] - } - private _replacePersistedSession( entry: ObservedSession, revision: SessionPersistenceRevision, @@ -552,9 +533,19 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(...SessionQuerySqlite._headerBindings(entry.header), revision, generation) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.header.delegationDepth ?? null, + revision, + generation, + ) const insert = db.prepare(` INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -578,9 +569,20 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(...SessionQuerySqlite._headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.header.delegationDepth ?? null, + entry.fingerprint, + persisted ? 1 : 0, + generation, + ) const insert = db.prepare(` INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -669,7 +671,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -679,7 +681,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -738,8 +740,6 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, - ps.sandbox_mode AS sandbox_mode, - ps.approval_policy AS approval_policy, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -762,8 +762,6 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, - ls.sandbox_mode AS sandbox_mode, - ls.approval_policy AS approval_policy, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -872,8 +870,6 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) - && a.sandboxMode === b.sandboxMode - && a.approvalPolicy === b.approvalPolicy } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -885,8 +881,6 @@ function rowHeader(row: SessionHeaderRow): 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 }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 1bd832aa47..47f6374ba6 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -117,8 +117,6 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, - sandbox_mode TEXT, - approval_policy TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -148,8 +146,6 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, - sandbox_mode TEXT, - approval_policy TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index bf0718f0b6..f7d2b3b352 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -225,40 +225,6 @@ describe('SQLite session search', () => { .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) - it('round-trips the inherited policy baselines through search headers', async () => { - // A delegated child's header carries the sandbox/approval baselines; the - // derived index must return them — a consumer resuming from a search hit - // would otherwise rebuild a child without its inherited confinement. - const ctx = await liveContext({ path: ':memory:' }) - const session = ctx.sessions.create(SessionId('live-baseline'), { - meta: { cwd: '/work', createdAt: 10, sandboxMode: 'read-only', approvalPolicy: 'never' }, - }) - session.append( - 'user/message', - createUserMessage({ content: [{ type: 'text', text: 'baseline needle' }], source: { kind: 'user' } }), - { surfaceOp: 'append' }, - ) - - const result = await ctx.sessionQuery.searchSessions({ query: 'needle' }) - expect(result.items[0]?.header).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - const events = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle' }) - expect(events.session).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - }) - - it('rejects live/persisted sources whose policy baselines conflict', async () => { - const shared = header('baseline-conflict', 10, { sandboxMode: 'read-only' }) - TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - ctx.sessions.create(shared.id, { - seed: messageEvents('live needle'), - meta: { createdAt: 10, sandboxMode: 'danger-full-access' }, - }) - - await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) - .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) - }) - it('searches all surfaces by default and applies metadata before ranking', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const parent = SessionId('parent') diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts index 2daccb6b28..f1bb64275f 100644 --- a/packages/session-query/session-query/src/sources.ts +++ b/packages/session-query/session-query/src/sources.ts @@ -17,8 +17,6 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade || a.parentSession !== b.parentSession || a.seedLength !== b.seedLength || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) - || a.sandboxMode !== b.sandboxMode - || a.approvalPolicy !== b.approvalPolicy ) { throw new SessionQueryError( `session source headers conflict for session "${a.id}"`, diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index c9c3a25249..621b896045 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -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 packages/subagent/subagent-inprocess/README.md -README.md: 95a45cd7a1f4510601f7f8d8bf396e7262f1a3cf -README.zh.md: c20f5106d0009f9c5507e830361c0fcba54d6280 +README.md: 3606799e6d16e80473006f82b834a10953270914 +README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 95a45cd7a1..3606799e6d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,8 +18,6 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. 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 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 The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index c20f5106d0..7f52d3699d 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,8 +18,6 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 -子 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))。 - ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index c24bafa4ad..50174037f8 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -102,17 +102,28 @@ export async function startInProcessRun( subagentDepth: childDepth, } - // 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. - // 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. + // Capture before the first await: a later parent switch belongs to the + // parent's future. Appending after the fork prefix makes the captured + // values the child's initial overrides without another storage plane. const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) + const seed: SessionEvent[] = [...options.seed ?? []] + if (inheritedMode !== undefined) { + seed.push({ + type: 'sandbox/mode', + seq: seed.length, + time: Date.now(), + data: { mode: inheritedMode, source: 'delegation' }, + }) + } + if (inheritedPolicy !== undefined) { + seed.push({ + type: 'approval/policy', + seq: seed.length, + time: Date.now(), + data: { policy: inheritedPolicy, source: 'delegation' }, + }) + } let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { @@ -134,10 +145,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 } : {}, + ...(options.seed !== undefined || seed.length > 0) ? { seed } : {}, agentOptions, signal: request.signal, setup, diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 47147bd3eb..07ba55ccfa 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -1,39 +1,17 @@ -/** - * Policy inheritance from parent to in-process child agents, proven against - * the REAL enforcement wall: a real loop drives a scripted mock MODEL whose - * children hit the real `dsh-fs-sandbox` fence through the real `write` tool, - * and every claim is asserted on physical facts — does the file exist on - * disk, what denial text landed in the child's tool result. Nothing here asks - * the policy service what it WOULD do; the child either writes or is denied. - * - * Red/green anchor for the delegation-bypass gap: a parent switched to - * `read-only` must not mint children that run under the (wider) deployment - * default. The captured overrides ride the child's creation-time header, so - * three review-found timing threats are pinned as distinct shapes: a parent - * switch racing the asynchronous creation, a veto-capable prompt-submit - * listener closing a promptless first turn, and an injection-persisted turn - * before any prompt turn opens (header asserted before the child runs). - */ +/** Policy inheritance through constructor-seeded child session events. */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' -import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' -import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService from '@deepseek-ai/dsh-subagent' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,130 +20,36 @@ import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]' - +const contexts: Context[] = [] let workspace: string beforeEach(async () => { - // realpath: macOS tmpdir is symlinked (/var → /private/var); resolve once so - // path assertions and the fence's canonicalization agree on one spelling. workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-'))) }) + afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() await rm(workspace, { recursive: true, force: true }) }) -async function mountInvariants(ctx: Context): Promise { - await ctx.plugin(InvariantService) - await ctx.plugin(SessionInvariant) - await ctx.plugin(AgentInvariant) - await ctx.plugin(AgentLoopInvariant) -} - -/** - * The walled composition: real loop + real sandbox-policy home + the real - * confining filesystem backend + the real `write` tool + the approval seam - * (mounted with NO answerer — the in-process child reality). The deployment - * default is deliberately WIDER (`workspace-write`) than the parent's staged - * `read-only` override, so a child that fails to inherit visibly escapes. - * - * The script array is taken by reference and filled by each test AFTER the - * parent exists, so scripted side-effect entries can close over it. - */ -async function setupWalled(script: Script) { +async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await mountInvariants(ctx) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) await ctx.plugin(ApprovalService) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }, { cwd: workspace }) + const parent = ctx.agentLoop.create( + SessionId('parent'), + { provider: 'mock', model: 'mock' }, + { cwd: workspace }, + ) return { ctx, parent } } -/** Bare composition: no sandbox, no fs, no approval — delegation must not care. */ -async function setupBare(script: Script) { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await mountInvariants(ctx) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) - ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } -} - -/** - * Register the delegation scratch tool: delegating from INSIDE an open parent - * turn is exactly the real tool-subagent shape, and it is what makes the - * "user switched while idle, model delegates in the very next turn" fork - * timing constructible (the post-seed switch lives in the still-open turn). - * `fork: true` seeds the child with the caller's completed-turn prefix, - * mirroring the fork provider's slice. `raceSwitch` flips the CALLER's mode - * synchronously after `startInProcessRun`'s synchronous prologue but before - * its creation transaction resolves — the delegation-vs-late-switch race. - */ -function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-full-access'): void { - ctx.tools.register(defineTool({ - name: 'delegate', - description: 'delegate a task to an in-process child (test scaffold)', - parameters: { fork: { type: 'boolean', description: 'seed the child with the completed-turn prefix' } }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - stopReason: { type: 'string', required: true }, - }, - }, - render: (_args, value) => [{ type: 'text', text: `child:${(value).stopReason}` }], - }, - async execute(args, exec) { - const caller = exec.agent - if (caller === undefined) throw new Error('delegate scaffold requires a calling agent') - const events = caller.session.events - const lastEnd = events.findLast(e => e.type === 'turn/end') - const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1) - const starting = startInProcessRun( - { prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal }, - args.fork === true && seed.length > 0 ? { seed } : {}, - ) - // The caller's turn is still open, so this switch is legal — and it lands - // while the child's creation transaction is pending, strictly before the - // child's first prompt-submit could ever run. - if (raceSwitch !== undefined) setSandboxMode(caller.session, raceSwitch) - const run = await starting - captured.push(run.localAgent as Agent) - const result = await run.result - await run.dispose() - return { stopReason: result.stopReason } - }, - })) -} - -/** All tool/result texts in a session log, in order. */ -function toolResultTexts(agent: Agent): string[] { - return agent.session.events - .filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') - .map(e => e.data.message.content - .flatMap(block => block.content) - .filter((block): block is Extract => block.type === 'text') - .map(block => block.text) - .join('')) -} - -/** Count the policy-override events in a session log. */ -function overrideEvents(agent: Agent): { sandbox: number; approval: number } { - const events = agent.session.events - return { - sandbox: events.filter(e => e.type === 'sandbox/mode').length, - approval: events.filter(e => e.type === 'approval/policy').length, - } -} - function spawnRequest(parent: Agent) { return { prompt: [{ type: 'text' as const, text: 'child task' }], @@ -174,320 +58,125 @@ function spawnRequest(parent: Agent) { } } -describe('sandbox-mode inheritance against the real fs fence', () => { - it('a SPAWN child of a read-only parent is denied by the real fence (no file on disk)', async () => { +function toolResultTexts(agent: Agent): string[] { + return agent.session.events + .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + .map(event => event.data.message.content + .flatMap(block => block.content) + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('')) +} + +describe('in-process policy inheritance', () => { + it('seeds parent overrides into a spawn child before its first request', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') - script.push( - // The switch is staged INSIDE a parent turn — the same turn-enclosed - // anchoring every real switch path (ACP pending switches) uses. - () => { - setSandboxMode(parent.session, 'read-only') - setApprovalPolicy(parent.session, 'never') - return textResponse('staged') - }, - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('child done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage the session policy' }], source: { kind: 'user' } })) - await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') + setApprovalPolicy(parent.session, 'never') const parentLogLength = parent.session.events.length - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - // The physical fact: the write never reached the disk. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - // The model-visible fact: the child saw the read-only denial marker. - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(result.stopReason).toBe('completed') - - // 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. - expect(parent.session.events.length).toBe(parentLogLength) - - 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') - }, + toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - 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() + try { + const result = await run.result + const child = run.localAgent as Agent + + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) + expect(result.stopReason).toBe('completed') + expect(child.session.events.slice(0, 2)).toMatchObject([ + { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, + { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, + ]) + expect(child.session.firstLiveSeq).toBe(2) + expect(child.session.header.seedLength).toBeUndefined() + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + expect(ctx.approval.overrideOf(child.session)).toBe('never') + const request = child.session.events.find( + (event): event is SessionEvent<'request/header'> => event.type === 'request/header', + ) + expect(request?.data.header.system).toContain('Approval prompts are disabled') + expect(parent.session.events).toHaveLength(parentLogLength) + } finally { + await run.dispose() + } }) - it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => { + it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { const script: Script = [] - const captured: Agent[] = [] const { ctx, parent } = await setupWalled(script) - registerDelegate(ctx, captured) const blocked = join(workspace, 'fork-blocked.txt') + setSandboxMode(parent.session, 'workspace-write') + const seed = [...parent.session.events] + setSandboxMode(parent.session, 'read-only') script.push( - // Turn 1: the OLD, wider switch — this one lands in the fork seed. - () => { - setSandboxMode(parent.session, 'workspace-write') - return textResponse('turn one') - }, - // Turn 2: the user tightened to read-only, then the model delegates in - // the SAME turn — the switch is in the log but past the seed slice. - () => { - setSandboxMode(parent.session, 'read-only') - return toolCallResponse('d-fork', 'delegate', { fork: true }) - }, - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('fork child done'), - textResponse('turn two done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn one' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn two: delegate' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const child = captured[0] as Agent - // The seed really carried the stale workspace-write switch… - expect(child.session.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) - // …and the newest parent state still won, on disk and in resolution. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - }) - - it('inherits the mode AT delegation, not a parent switch racing child creation', async () => { - const script: Script = [] - const captured: Agent[] = [] - const { ctx, parent } = await setupWalled(script) - // The delegate scaffold flips the parent to danger-full-access AFTER - // startInProcessRun's synchronous prologue, while the child's creation - // transaction is still pending — the value at delegation is read-only. - registerDelegate(ctx, captured, 'danger-full-access') - const blocked = join(workspace, 'race-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - toolCallResponse('d-race', 'delegate', { fork: false }), - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('race child done'), - textResponse('turn two done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const child = captured[0] as Agent - // The child runs under the snapshot taken at delegation — the racing - // wider switch belongs to the parent's own future, not to the child. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - }) - - it('a GRANDCHILD inherits through the chain (child delegates again)', async () => { - const script: Script = [] - const captured: Agent[] = [] - const { ctx, parent } = await setupWalled(script) - registerDelegate(ctx, captured) - const blocked = join(workspace, 'grandchild-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - toolCallResponse('d-child', 'delegate', { fork: false }), - // Child immediately delegates the write to a grandchild. - toolCallResponse('d-grandchild', 'delegate', { fork: false }), - toolCallResponse('g-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('grandchild done'), + toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), - textResponse('parent done'), ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate twice' }], source: { kind: 'user' } })) - await parent.whenIdle() - expect(captured).toHaveLength(2) - const grandchild = captured[1] as Agent - expect(grandchild.session.header.delegationDepth).toBe(2) - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(grandchild).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: grandchild.session }).mode).toBe('read-only') + const run = await startInProcessRun(spawnRequest(parent), { seed }) + try { + await run.result + const child = run.localAgent as Agent + + expect(child.session.header.seedLength).toBe(1) + expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { seq: 0, data: { mode: 'workspace-write' } }, + { seq: 1, data: { mode: 'read-only', source: 'delegation' } }, + ]) + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + + setSandboxMode(child.session, 'danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + } finally { + await run.dispose() + } }) -}) -describe('inheritance survives prompt vetoes', () => { - it('keeps the baseline when an earlier-registered prompt-submit listener vetoes without next()', async () => { - const script: Script = [] + it('captures policy at delegation before asynchronous child creation', async () => { + const script: Script = [textResponse('child done')] const { ctx, parent } = await setupWalled(script) - // A veto-capable listener registered BEFORE the child exists — the - // Claude/Codex UserPromptSubmit hook shape: it blocks the child's prompt - // and never delegates. Inheritance must still run for the first turn. - ctx.on('agent/prompt-submit', (agent, _message, _signal, next) => { - if (agent.session.header.parentSession !== undefined) { - return Promise.resolve({ kind: 'block' as const, reason: 'vetoed by test hook' }) - } - return next() - }) - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - // No child model entries: the blocked prompt closes a zero-step turn. - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') - const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // 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() + const starting = startInProcessRun(spawnRequest(parent), {}) + setSandboxMode(parent.session, 'danger-full-access') + const run = await starting + try { + await run.result + const child = run.localAgent as Agent + expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + } finally { + await run.dispose() + } }) -}) -describe('inheritance guards (must hold before AND after the fix)', () => { - it('a child of an unswitched parent runs under the live deployment default, with NO baseline or events', async () => { + it('does not freeze deployment defaults into an unswitched child', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') script.push( - toolCallResponse('c-write', 'write', { file_path: allowed, content: 'fine' }), + toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }), textResponse('child done'), ) const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // workspace-write (the deployment default) really allowed the write… - expect(await readFile(allowed, 'utf8')).toBe('fine') - // …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() - }) - - it('delegation works unchanged when no sandbox/approval services are composed at all', async () => { - const script: Script = [] - const { parent } = await setupBare(script) - script.push(textResponse('bare child answer')) - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - expect(result.stopReason).toBe('completed') - expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) - - await run.dispose() - }) -}) - -describe('what a blocked child experiences', () => { - it('an inherited "never" policy is stated in the child FIRST request system prompt', async () => { - const script: Script = [] - const { parent } = await setupWalled(script) - script.push( - () => { - setApprovalPolicy(parent.session, 'never') - return textResponse('staged') - }, - textResponse('child done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // Model-visible ⟺ logged: the child was TOLD up front not to request - // escalation, in the very first request it ever saw. - const header = child.session.events.find((e): e is SessionEvent<'request/header'> => e.type === 'request/header') - expect(header?.data.header.system).toContain('Approval prompts are disabled') - - await run.dispose() - }) - - it('a denied child that retries with sandbox_permissions fails closed on the REAL escalation gate', async () => { - const script: Script = [] - const { parent } = await setupWalled(script) - const blocked = join(workspace, 'escalation-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - // First attempt: denied by the fence. - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - // One-shot escalation retry, exactly as the denial hint teaches — the - // approval seam is mounted but NO answerer owns an in-process child. - toolCallResponse('c-escalate', 'write', { - file_path: blocked, - content: 'escaped', - sandbox_permissions: 'workspace-write', - justification: 'the test child wants to write inside the workspace', - }), - textResponse('child gave up'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - // Nothing ever reached the disk — not the first attempt, not the retry. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - const results = toolResultTexts(child) - expect(results[0]).toContain(READ_ONLY_DENIAL) - // The child's escalation resolves through the real approval waterfall to - // the distinct fail-closed reason — the honest "report upward" signal. - expect(results[1]).toContain('no approval channel is available') - expect(result.stopReason).toBe('completed') - - await run.dispose() + try { + await run.result + const child = run.localAgent as Agent + expect(await readFile(allowed, 'utf8')).toBe('fine') + expect(child.session.events.some( + event => event.type === 'sandbox/mode' || event.type === 'approval/policy', + )).toBe(false) + expect(child.session.firstLiveSeq).toBe(0) + } finally { + await run.dispose() + } }) }) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index e7ff3fa2a7..c29bf60910 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -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: 2d01844d8391a530ec06878c0b77b20bf74d6f12 -README.zh.md: 4456f53291b64ce14a6eaea5204050ecfc043560 +README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa +README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 2d01844d83..6a59ad9425 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. Both it and `current(session)` resolve the knobs through the same override chains execution reads (`sandboxOverrideOf`/`approvalOverrideOf`: own post-seed switches, else the inherited header baseline, else composition defaults), so a delegated child inheriting a wider baseline gets real knob switches when a narrower preset is selected, and a seed-carried selection is subsumed by the baseline. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` prefers a still-matching recorded own selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 4456f53291..0e7db1bd41 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -4,7 +4,7 @@ 通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。它与 `current(session)` 都通过执行所读取的同一套覆盖链解析调节项(`sandboxOverrideOf`/`approvalOverrideOf`:先取会话自己在种子之后的切换,否则取会话头中继承的基线,否则取组合默认值),因此继承了更宽基线的被委派子 agent(智能体)在选中更窄的 preset 时会得到真实的调节项切换,而种子携带的选择会被基线所涵盖。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 优先返回仍与当前调节项匹配的、会话自己的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 -- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent/profile 选择尚未纳入 `PresetSpec`。 +- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 616a92c3f3..d44dff3df4 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -12,12 +12,12 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { SANDBOX_MODES, sandboxOverrideOf, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.bash` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import { APPROVAL_POLICIES, approvalOverrideOf, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' declare module 'cordis' { interface Context { @@ -139,24 +139,17 @@ export class PermissionService extends Service { } /** - * Resolve the preset matching the effective knob values — the same - * 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 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. + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. * @returns the effective preset name, or `custom` when nothing matches. */ - current(session: Session): string { - const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode - const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask' + current(events: readonly SessionEvent[]): string { + const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode + const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined - const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events) + const folded = effectivePermissionPreset(events) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded @@ -204,17 +197,14 @@ export class PermissionService extends Service { */ set(session: Session, name: string): void { const spec = this.resolve(name) - if (this.current(session) !== name) { + if (this.current(session.events) !== name) { session.append('permission/preset', { preset: name }) } - // Compare against the SAME override chains current() derives from: a - // child inheriting a wider baseline must get real knob switches when the - // user selects a narrower preset — an event-only fold would believe the - // preset is already active and silently leave enforcement at the baseline. - if (spec.sandbox !== (sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode)) { + const events = session.events + if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { setApprovalPolicy(session, spec.approval) } } diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 4133ee7bbf..864f25629d 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -48,25 +48,25 @@ describe('PermissionService', () => { it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => { const ctx = await mounted() const session = freshSession('sess-current') - expect(ctx.permission.current(session)).toBe('workspace-write') + expect(ctx.permission.current(session.events)).toBe('workspace-write') ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) it('a knob state matching no table entry derives custom — a state, not an error', async () => { const ctx = await mounted() const session = freshSession('sess-custom') session.append('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) it('composition defaults outside the table derive custom at zero events', async () => { const ctx = await mounted({ approvalDefault: 'never' }) const session = freshSession('sess-defaults-custom') - expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) }) it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { @@ -77,10 +77,10 @@ describe('PermissionService', () => { } } }) const session = freshSession('sess-tie') ctx.permission.set(session, 'agentish') - expect(ctx.permission.current(session)).toBe('agentish') + expect(ctx.permission.current(session.events)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permission.current(session)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -140,47 +140,6 @@ describe('PermissionService', () => { const session = freshSession('sess-standin') ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permission.current(session)).toBe('workspace-write') - }) - - it('derives current() from an inherited header baseline and switches AWAY from it for real', async () => { - const ctx = await mounted() - // A delegated child: danger-full-access baseline over the composition's - // workspace-write/ask defaults — the child header, not the event log, - // carries the effective knobs. - const id = SessionId('sess-inherited-preset') - const child = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - sandboxMode: 'danger-full-access', - approvalPolicy: 'never', - }) - expect(ctx.permission.current(child)).toBe('danger-full-access') - - // Selecting workspace-write must APPEND both knob switches: folding only - // events would believe workspace-write is already active and silently - // leave enforcement at the inherited danger-full-access. - ctx.permission.set(child, 'workspace-write') - expect(child.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) - expect(child.events.some(e => e.type === 'approval/policy' && e.data.policy === 'ask')).toBe(true) - expect(ctx.permission.current(child)).toBe('workspace-write') - }) - - it('ignores a seed-carried preset selection in favor of the delegation baseline', async () => { - const ctx = await mounted() - const id = SessionId('sess-seeded-preset') - const seeded = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - sandboxMode: 'danger-full-access', - approvalPolicy: 'never', - seedLength: 1, - }) - // The fork seed carried the PARENT's old selection event; the baseline - // captured after it owns the child's truth. - seeded.append('permission/preset', { preset: 'workspace-write' }) - expect(ctx.permission.current(seeded)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 0b80c98b63..083bc84d20 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 packages/ui/user-approval/README.md -README.md: c29ada50950cb6b40065a09bac1becbf4afb0ce4 -README.zh.md: c119f2009298edfaa101b10c7562d0c36324ec5d +# pnpm run verify-translation-pairing --write +README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64 +README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index c29ada5095..38bcfbfe81 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -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 session's override chain (`approvalOverrideOf`, below), falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`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. The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). @@ -18,7 +18,7 @@ The tools pipeline routes `ask` decisions through this seam and fails closed whe #### What the model sees -Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).`, `The approval policy changed from "" to "" (inherited from the delegating session).`, or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. +Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).` or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. ##### Ask-policy prompt section diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index c119f20092..2a3a6d08d6 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取会话的覆盖链(见下文 `approvalOverrideOf`),并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知,并按其在会话自己的事件中的位置归因:如果会话自己的覆盖出现在自己最后一个 `request/header` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -18,7 +18,7 @@ #### 模型看到的内容 -在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).`、`The approval policy changed from "" to "" (inherited from the delegating session).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 +在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 ##### Ask 策略提示词段 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 644cfd9153..e4f60da037 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -59,13 +59,16 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The last such OWN - * (post-seed) event is the session's override - * ({@link approvalOverrideOf}); who asked for it is derivable from - * position (an own event after the log's last own `request/header` was a - * runtime switch by the user). + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}). + * `source: 'delegation'` marks an override seeded into a child; an absent + * source is a runtime switch. */ - 'approval/policy': { policy: ApprovalPolicy } + 'approval/policy': { + policy: ApprovalPolicy + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' + } } } @@ -124,12 +127,10 @@ function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefi } /** - * The pure fold of a slice of `approval/policy` events: the last switch - * wins, or undefined without one. The building block - * {@link approvalOverrideOf} composes with the seed boundary and the header - * baseline — consumers resolving a SESSION's policy go through that chain, - * not this raw fold. Resume needs no catch-up machinery because replaying - * the log IS the state. + * The session's approval-policy override: the last `approval/policy` event in + * the log, or undefined when the session never switched (callers apply the + * plugin's configured default). The pure fold — resume needs no catch-up + * machinery because replaying the log IS the state. * @param events - session events in log order (other event types are skipped). * @returns the policy of the last switch event, or undefined without one. */ @@ -141,41 +142,6 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } -/** - * The session's complete approval-policy OVERRIDE chain — the one home every - * consumer (this service's policy tier, 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. 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. - * @throws when the header baseline is outside the closed policy vocabulary. - */ -export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { - const baseline = session.header.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`) - } - // A boundary past the log would make the own-switch slice empty until the - // log grows past it — a baseline would then shadow a REAL later switch. - // Malformed durable metadata fails loud, never fails open. - const seedLength = session.header.seedLength ?? 0 - if (seedLength > session.events.length) { - throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) - } - const own = effectiveApprovalPolicy(session.events.slice(seedLength)) - return own ?? baseline as ApprovalPolicy -} - /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -279,30 +245,28 @@ export class ApprovalService extends Service { // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told // is in-memory with a log-derived fallback (the folded header's system - // text), so restarts lose nothing. Attribution is positional over the - // session's OWN events (past the seed boundary — a seed-carried switch is - // stale parent history, never this session's runtime action): an own - // override after the last own `request/header` was a runtime switch by - // the user; no own override with the delta matching the inherited header - // baseline came from the delegating session; otherwise the configured - // default moved under the session (operator/config). + // text), so restarts lose nothing. Attribution is positional: an + // override event after the log's last `request/header` was a runtime + // switch by the user; otherwise the configured default moved under the + // session (operator/config). const narrated = new WeakMap() ctx.on('agent/step', (agent) => { const session = agent.session const events = session.events - const seedStart = Math.min(session.header.seedLength ?? 0, events.length) let overrideIndex = -1 + let overrideSource: 'delegation' | undefined let headerIndex = -1 - for (let index = events.length - 1; index >= seedStart && (overrideIndex < 0 || headerIndex < 0); index -= 1) { + for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) { const event = events[index] as (typeof events)[number] if (overrideIndex < 0 && event.type === 'approval/policy') { overrideIndex = index + overrideSource = event.data.source } else if (headerIndex < 0 && event.type === 'request/header') { headerIndex = index } } - // Same fold effectivePolicy performs — the own override is scanned here - // anyway for POSITIONAL attribution; the default lives once, in the method. + // Same fold effectivePolicy performs — override is scanned here anyway + // for POSITIONAL attribution; the default lives once, in the method. const current = this.effectivePolicy(session) const header = session.requestHeader() const told = narrated.get(session) ?? toldApprovalPolicy(header?.system) @@ -310,11 +274,9 @@ export class ApprovalService extends Service { // Cold start (nothing ever told) narrates nothing — the section about // to go out states the truth, and there is no delta to explain. if (told === undefined || told === current) return - const cause = overrideIndex > headerIndex - ? 'changed by the user' - : overrideIndex < 0 && session.header.approvalPolicy === current - ? 'inherited from the delegating session' - : 'changed by the operator/config' + const cause = overrideSource === 'delegation' + ? 'inherited from the delegating session' + : overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config' agent.inject(createUserMessage({ content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }], source: { kind: 'plugin', plugin: 'user-approval' }, @@ -362,9 +324,9 @@ export class ApprovalService extends Service { } /** - * The session's effective policy: its override chain ({@link overrideOf}), - * else the configured default (the schema already defaulted an omitted - * policy to `'ask'`; the `??` only narrows the optional-input TYPE). + * The session's effective policy: its own `approval/policy` fold, else the + * configured default (the schema already defaulted an omitted policy to + * `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. * @returns the policy every ask for this session resolves under right now. */ @@ -373,15 +335,12 @@ export class ApprovalService extends Service { } /** - * {@link approvalOverrideOf} surfaced on the service, for consumers that - * reach the seam through `ctx.get('approval')` (the subagent driver's - * delegation capture) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the configured default. + * Read the session override without applying the configured default. + * @param session - session whose log supplies the override. + * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return approvalOverrideOf(session) + return effectiveApprovalPolicy(session.events) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 875e284807..0d586049fa 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -20,9 +20,6 @@ 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) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -459,7 +456,9 @@ describe('approval policy (the approval/policy fold)', () => { await ctx.plugin(ApprovalService, { policy: 'never' }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const { agent, session } = sessionAgent('sess-gate-3') + expect(ctx.approval.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'ask') + expect(ctx.approval.overrideOf(session)).toBe('ask') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once') setApprovalPolicy(session, 'never') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') @@ -510,6 +509,18 @@ describe('approval policy (the approval/policy fold)', () => { expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).']) }) + it('attributes a constructor-seeded policy event to delegation', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session, injected } = sessionAgent('sess-narr-inherited') + appendHeader(session, ASK_MARKER) + session.append('approval/policy', { policy: 'never', source: 'delegation' }) + + await preStep(ctx, agent) + + expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) + }) + it('narrates a config default drift from the logged ask marker', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) @@ -519,38 +530,6 @@ describe('approval policy (the approval/policy fold)', () => { expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).']) }) - it('does not attribute a fork child\'s baseline delta to a stale seed-carried user switch', async () => { - // A fork child: the seed carries the parent's OLD 'ask' switch (event 0, - // inside seedLength) and the last request header told 'ask'; the header - // baseline captured at delegation is 'never'. The delta must not be - // attributed to "the user" — the seed switch is stale parent history, not - // this session's runtime action. - const ctx = new Context() - await ctx.plugin(ApprovalService) - const id = SessionId('sess-narr-fork-baseline') - const session = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - approvalPolicy: 'never', - seedLength: 2, - }) - setApprovalPolicy(session, 'ask') - session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: `persona\n${ASK_MARKER}` }, reason: 'initial' }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const injected: string[] = [] - const agent = { - id, - session, - inject: (input: { content: Array<{ type: string; text: string }> }) => { - injected.push(input.content[0]?.text ?? '') - }, - } as unknown as Agent - - await preStep(ctx, agent) - expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) - }) - it('a pinned override survives a default change silently', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) @@ -613,88 +592,3 @@ describe('approval policy (the approval/policy fold)', () => { expect(afterDispose.injected).toEqual([]) }) }) - -describe('delegation inheritance (overrideOf over the header baseline)', () => { - function bareSession(id: string): Session { - return new Session(SessionId(id)) - } - - /** 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') - - expect(ctx.approval.overrideOf(parent)).toBe('never') - expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() - }) - - it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-baseline', { approvalPolicy: '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('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { - const ctx = await mounted() - 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') - }) - - 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(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) - }) - - it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-invalid-own', { approvalPolicy: 'always' }) - setApprovalPolicy(child, 'never') - - 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') - }) - - it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-oob', { approvalPolicy: 'ask', seedLength: 100 }) - setApprovalPolicy(child, 'never') - - expect(() => ctx.approval.overrideOf(child)).toThrow(/seedLength/) - }) -})