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 1/5] 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/) - }) -}) From a70f1a2b7a8afaa87c0eeff0087b58fad892bf09 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:32:10 +0800 Subject: [PATCH 2/5] test(acp): remove vacuous policy inheritance scenario The ACP fixture configured read-only as the deployment default for both parent and child. Its delegated write therefore remained denied even with inheritance disabled, so the scenario could not fail on the regression it claimed to protect. Delete the overlay, scenario registration, sessions, prompt, and 473-line tool-schema sidecar. The Loader-booted headless snapshot remains the real composition guard: only its parent carries read-only while the deployment default is workspace-write, so removing inheritance makes the child write reach disk and fails the test. Keeping one discriminating snapshot avoids 662 lines of duplicated fixture data and makes the review evidence correspond to the actual security boundary. --- .../subagent-inheritance.cordis.snapshot.yml | 45 -- .../acp-agent/subagent-inheritance.cordis.yml | 25 - examples/acp-agent/tests/acp.snapshot.ts | 17 - .../subagent-sandbox-inheritance/input.json | 14 - .../session.1.jsonl | 30 -- .../session.jsonl | 30 -- .../stdout.expected.jsonl | 4 - .../system-prompt.expected.md | 24 - .../tool-schemas.expected.json | 473 ------------------ 9 files changed, 662 deletions(-) delete mode 100644 examples/acp-agent/subagent-inheritance.cordis.snapshot.yml delete mode 100644 examples/acp-agent/subagent-inheritance.cordis.yml delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json diff --git a/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml deleted file mode 100644 index a7a9302822..0000000000 --- a/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Keyless replay counterpart of subagent-inheritance.cordis.yml: the same -# flash pin plus the standard replay swaps (disable the key-requiring adapter, -# passthrough sandbox runner, insert llm-replay). Patches do not compose -# across nested includes, so everything applies together over the live tree. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - config: - runnerCommand: - - bash - - -c - - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" - - passthrough-runner - runnerFailureSignatures: - - 'passthrough-runner: profile rejected' - - id: acp-agent - name: '@deepseek-ai/dsh-acp-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - persistenceCompression: none - workspaceContext: - maxBytes: 65536 - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek - name: DeepSeek - models: - - id: deepseek-v4-flash - - id: deepseek-v4-pro diff --git a/examples/acp-agent/subagent-inheritance.cordis.yml b/examples/acp-agent/subagent-inheritance.cordis.yml deleted file mode 100644 index 2757d95d1f..0000000000 --- a/examples/acp-agent/subagent-inheritance.cordis.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Subagent-under-confinement snapshot overlay: pin the recorded model to -# deepseek-v4-flash so this scenario's request headers match the recorded -# sandbox-class corpus (cordis.yml ships deepseek-v4-pro for live use). The -# read-only policy itself comes from the scenario's DSH_PERMISSION_MODE env — -# the automation protocol has no session-scoped picker, so deployment policy -# is the lever ([downgrade rationale in the scenario table]). A config patch -# replaces the whole target config, so base fields are restated verbatim. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: acp-agent - name: '@deepseek-ai/dsh-acp-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - workspaceContext: - maxBytes: 65536 - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9f3007b770..a6c91bd5cd 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,7 +38,6 @@ const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml' const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) -const SUBAGENT_INHERITANCE_CONFIG = fileURLToPath(new URL('../subagent-inheritance.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) @@ -264,22 +263,6 @@ const SCENARIOS: Scenario[] = [ headerClass: 'sandbox', env: { DSH_PERMISSION_MODE: 'workspace-write' }, }, - // Delegation under a read-only DEPLOYMENT policy: the child's real write is - // denied by the fs fence and its denial report surfaces in the parent - // transcript. This is the strength the automation protocol can express — no - // session-scoped picker exists to tighten only the parent, so the - // parent-override-vs-wider-default half of the inheritance contract stays - // pinned by the real-wall unit suite (the subagent policy-inheritance Agent - // Note records the downgrade). - { - name: 'subagent-sandbox-inheritance', - hasModelTurn: true, - recorded: true, - pinsHeader: true, - headerClass: 'subagent-inheritance', - configPath: SUBAGENT_INHERITANCE_CONFIG, - env: { DSH_PERMISSION_MODE: 'read-only' }, - }, // Unlike ordinary snapshots, this session cwd is outside the platform temp // roots that workspace-write always grants. The overlay points the // deployment fallback at /tmp, so a successful relative write proves the diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json deleted file mode 100644 index d0be3ce419..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "steps": [ - { - "op": "initialize" - }, - { - "op": "newSession" - }, - { - "op": "prompt", - "text": "Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation." - } - ] -} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl deleted file mode 100644 index 577d03330d..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"7fcdaf99-35c9-4ad6-a872-fc04fbfe4ee6","createdAt":1784961244926,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-XKPvGB","parentSession":"03d6a514-045b-4f61-9a8f-1b5165c3a648","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784961244928,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784961244928,"data":{"content":[{"type":"text","text":"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \"inherited.txt\" (never an absolute path) and content set to \"Child agent wrote this file.\" (escaped as needed). \n\nIf the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\n\nIf the write succeeds, reply with the single word CHILD_WROTE.\n\nDo not use any other tools or do anything else."}],"source":{"kind":"user"},"role":"user","id":"794cb9e6-5770-40bf-a0c6-69da4a71fe01"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784961244929,"data":{"title":"You have access to the","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784961244932,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784961244933,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784961245908,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784961245909,"data":{"turn":1,"step":1,"index":0,"dt":[145,8,0,1,0,46,1,0,0,16,1,0,30,1,32,1,0,0,0,1,29,0,1,34,0,1,0,30,1,30,0,0,32,33,1,0,0,1,0,28,33,1,0,0,33,0,1,0,0,31,0,1,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," write"," tool"," exactly"," once"," with"," file","_path","=\"","inher","ited",".txt","\""," and"," content","=\"","Child"," agent"," wrote"," this"," file",".\"."," If"," denied",","," I"," should"," reply"," with"," CH","ILD","_D","EN","IED"," followed"," by"," the"," denial"," marker","."," If"," successful",","," reply"," with"," CH","ILD","_W","RO","TE","."]}} -{"type":"assistant/chunk","seq":63,"time":1784961246638,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":64,"time0":1784961246639,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,31,1,0,0,31,1,0,0,1,63,1,0,0,1,32,1,0,0,0,1,32,0,1],"id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","args":["","{","\"","file","_path","\"",": ","\"","inher","ited",".txt","\"",", ","\"","content","\"",": ","\"","Child"," agent"," wrote"," this"," file",".","\"","}"]}} -{"type":"assistant/chunk","seq":90,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path=\"inherited.txt\" and content=\"Child agent wrote this file.\". If denied, I should reply with CHILD_DENIED followed by the denial marker. If successful, reply with CHILD_WROTE."}}}} -{"type":"assistant/chunk","seq":91,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}}}} -{"type":"assistant/chunk","seq":92,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5377,"outputTokens":123,"cacheReadTokens":0,"reasoningTokens":57}}}} -{"type":"assistant/chunk","seq":93,"time":1784961246904,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":94,"time":1784961246905,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path=\"inherited.txt\" and content=\"Child agent wrote this file.\". If denied, I should reply with CHILD_DENIED followed by the denial marker. If successful, reply with CHILD_WROTE."},{"type":"tool-call","id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"aacd17fa-e534-4a19-8e8a-db123fc0b7b1"},"usage":{"inputTokens":5377,"outputTokens":123,"cacheReadTokens":0,"reasoningTokens":57}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"tool/call","seq":95,"time":1784961246905,"data":{"turn":1,"step":1,"callId":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}} -{"type":"tool/result","seq":96,"time":1784961246918,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_7iJutQqZ95RcVbXUefTC5135"},"content":[{"type":"tool-result","toolCallId":"call_00_7iJutQqZ95RcVbXUefTC5135","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":"2c6046db-7862-4010-b614-4fd09850bedb"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[95],"surfaceOp":"append"} -{"type":"step/end","seq":97,"time":1784961246924,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":98,"time":1784961246925,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":99,"time":1784961247934,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":100,"time0":1784961247934,"data":{"turn":1,"step":2,"index":0,"dt":[119,43,2,1,0,20,0,0,1,0,33,1,0,0,0,0,36,0,0,1,32,2,1,0,0,0,25,0,0,0,1,36,1,0,0,0,25,1,0,0,0,0,31,1,0,0,0,34,1,0,1,0,0,33,0,0,1,32,1,0,0,0,32,0,1,0,0,35,0,0,0,0,1,32,1,0,0,0,1,32,38,0,0,1,0,29,1,0,0,1,0,30,0,0,1,0,32,0,0,1,36,1,28,1,0,0,0,1,34,1,0,0,0,0,29,1,0,0,0],"texts":["The"," write"," was"," denied"," by"," the"," sand","box"," under"," read","-only"," mode","."," According"," to"," the"," instructions",":"," \"","If"," the"," write"," is"," denied"," by"," the"," sand","box"," (","look"," for"," \"[","sand","box",":"," file"," access"," denied","\""," in"," the"," result","),"," do"," NOT"," ret","ry"," and"," do"," NOT"," request"," escalation","."," Reply"," with"," the"," single"," word"," CH","ILD","_D","EN","IED"," followed"," by"," a"," space"," and"," then"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result",".\"\n\n","The"," denial"," marker"," line"," is",":"," \"[","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","\"\n\n","So"," I"," should"," reply",":"," CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]"]}} -{"type":"assistant/chunk","seq":220,"time":1784961248883,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":221,"time0":1784961248884,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,30,1,0,0,0,0,35,0,0,0,1],"texts":["CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]"]}} -{"type":"assistant/chunk","seq":238,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied by the sandbox under read-only mode. According to the instructions: \"If the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\"\n\nThe denial marker line is: \"[sandbox: file access denied under read-only mode]\"\n\nSo I should reply: CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":239,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":240,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":138,"cacheReadTokens":5376,"reasoningTokens":120}}}} -{"type":"assistant/chunk","seq":241,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":242,"time":1784961248952,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The write was denied by the sandbox under read-only mode. According to the instructions: \"If the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\"\n\nThe denial marker line is: \"[sandbox: file access denied under read-only mode]\"\n\nSo I should reply: CHILD_DENIED [sandbox: file access denied under read-only mode]"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7fb28534-4a44-435d-8d15-403f7ee1bebd"},"usage":{"inputTokens":188,"outputTokens":138,"cacheReadTokens":5376,"reasoningTokens":120}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241],"surfaceOp":"append"} -{"type":"step/end","seq":243,"time":1784961248963,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":244,"time":1784961248963,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl deleted file mode 100644 index 383e179fe5..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"03d6a514-045b-4f61-9a8f-1b5165c3a648","createdAt":1784961240155,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-XKPvGB","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784961240159,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784961240159,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation."}],"source":{"kind":"user"},"role":"user","id":"6ac76657-3b3d-4661-ac5f-973dbf9a8b82"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784961240160,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784961240164,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784961240164,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784961241327,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784961241328,"data":{"turn":1,"step":1,"index":0,"dt":[11,32,1,0,0,0,0,32,0,0,1,0,0,57,1,2,0,33,0,0,44,0,0,29,1,31,26,1,0,0,0,40,31,1,0,0,25,1,0,45,19,0,0,1,0,0,41,1,0,0,0,22,1,0,0,0,0,33,0,0,1,30,0,0,0,0,33,1,0,0,0,49,0,0,1,0,17,1,0,0,0,0,28,1,0,0,0,0,39,0,1,0,0,30,1,0,26,1,0,0,31,1,0,41,1,0,0,0,0,25,0,0,0,34,1,0,0,0,0,39,0,31,0,0,0,0,0,30,1,0,0,0,30,1,24,0,0,1,0,0,44,1,18,2,1,0,0,0,31,1,0,0,0,30,39,1,0,0,0,1,27,1,0,0,0,0,29,1,0,0,0,0,37,1,0,0,26,1,0,32,1,0,31,1,0,0,33,0,0,33,1,0,0,31,1,0,1,30,1,0,33,1,0,0,30,33,31,1,32,1,0,1,30,33,0,1],"texts":["The"," user"," wants"," me"," to"," delegate"," a"," subt","ask"," to"," a"," sub","agent","."," The"," subt","ask"," is",":"," use"," the"," write"," tool"," to"," write"," to"," `","inher","ited",".txt","`"," with"," content"," escaped","."," If"," denied",","," reply"," with"," CH","ILD","_D","EN","IED"," and"," the"," denial"," marker","."," If"," succeeds",","," reply"," with"," CH","ILD","_W","RO","TE",".\n\n","After"," the"," sub","agent"," returns",","," I"," need"," to"," state"," whether"," the"," child"," was"," denied"," by"," the"," sand","box",","," quoting"," the"," denial"," marker"," if"," there"," is"," one",","," then"," reply"," with"," PAR","ENT","_D","ONE",".\n\n","Let"," me"," carefully"," construct"," the"," sub","agent"," prompt",".\n\n","The"," sub","agent"," should",":\n","1","."," Use"," the"," write"," tool"," with"," file","_path"," ="," \"","inher","ited",".txt","\""," (","relative"," path",")\n","2","."," If"," the"," write"," is"," denied",","," reply"," with"," \"","CH","ILD","_D","EN","IED","\""," and"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result","\n","3","."," If"," it"," succeeds",","," reply"," with"," \"","CH","ILD","_W","RO","TE","\"\n\n","I"," need"," to"," make"," sure"," the"," sub","agent"," prompt"," is"," self","-contained"," and"," clear","."," The"," sub","agent"," doesn","'t"," share"," this"," conversation"," context",","," so"," I"," need"," to"," explain"," what"," tools"," it"," has"," available",".\n\n","Let"," me"," craft"," the"," prompt"," carefully","."]}} -{"type":"assistant/chunk","seq":223,"time":1784961243256,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":224,"time0":1784961243257,"data":{"turn":1,"step":1,"index":1,"dt":[31,1,0,0,0,31,0,1,0,31,34,31,1,0,0,0,32,2,0,0,30,0,1,31,1,0,0,32,0,31,1,0,0,0,34,0,1,0,30,1,0,0,1,0,32,1,0,0,1,0,31,1,0,31,1,32,1,32,1,33,1,41,1,0,23,35,0,34,29,1,42,1,1,0,21,1,34,1,0,0,33,31,1,32,0,0,1,31,1,0,31,0,0,33,1,0,0,0,1,31,1,0,0,31,0,1,38,1,0,25,0,0,33,1,1,32,1,32,1,0,1,39,0,1,0,25,1,0,0,1,30,1,0,0,0,32,1,0,0,0,0,31,0,0,33,1,0,0,0,33,0,0,43,0,25,0,0,0,30],"id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","args":["","{","\"","description","\"",": ","\"","Write"," inherited",".txt"," task","\"",", ","\"","prom","pt","\"",": ","\"","You"," have"," access"," to"," the"," write"," tool","."," Use"," the"," write"," tool"," exactly"," once"," with"," file","_path"," set"," to"," exactly"," the"," relative"," path"," \\\"","inher","ited",".txt","\\\""," (","never"," an"," absolute"," path",")"," and"," content"," set"," to"," \\\"","Child"," agent"," wrote"," this"," file",".\\\""," (","esc","aped"," as"," needed",")."," \\n\\n","If"," the"," write"," is"," denied"," by"," the"," sand","box"," (","look"," for"," \\\"[","sand","box",":"," file"," access"," denied","\\\""," in"," the"," result","),"," do"," NOT"," ret","ry"," and"," do"," NOT"," request"," escalation","."," Reply"," with"," the"," single"," word"," CH","ILD","_D","EN","IED"," followed"," by"," a"," space"," and"," then"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result",".\\n\\n","If"," the"," write"," succeeds",","," reply"," with"," the"," single"," word"," CH","ILD","_W","RO","TE",".\\n\\n","Do"," not"," use"," any"," other"," tools"," or"," do"," anything"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":384,"time":1784961244901,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to delegate a subtask to a subagent. The subtask is: use the write tool to write to `inherited.txt` with content escaped. If denied, reply with CHILD_DENIED and the denial marker. If succeeds, reply with CHILD_WROTE.\n\nAfter the subagent returns, I need to state whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with PARENT_DONE.\n\nLet me carefully construct the subagent prompt.\n\nThe subagent should:\n1. Use the write tool with file_path = \"inherited.txt\" (relative path)\n2. If the write is denied, reply with \"CHILD_DENIED\" and the exact denial marker line from the tool result\n3. If it succeeds, reply with \"CHILD_WROTE\"\n\nI need to make sure the subagent prompt is self-contained and clear. The subagent doesn't share this conversation context, so I need to explain what tools it has available.\n\nLet me craft the prompt carefully."}}}} -{"type":"assistant/chunk","seq":385,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}}}} -{"type":"assistant/chunk","seq":386,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5393,"outputTokens":418,"cacheReadTokens":0,"reasoningTokens":217}}}} -{"type":"assistant/chunk","seq":387,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":388,"time":1784961244908,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to delegate a subtask to a subagent. The subtask is: use the write tool to write to `inherited.txt` with content escaped. If denied, reply with CHILD_DENIED and the denial marker. If succeeds, reply with CHILD_WROTE.\n\nAfter the subagent returns, I need to state whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with PARENT_DONE.\n\nLet me carefully construct the subagent prompt.\n\nThe subagent should:\n1. Use the write tool with file_path = \"inherited.txt\" (relative path)\n2. If the write is denied, reply with \"CHILD_DENIED\" and the exact denial marker line from the tool result\n3. If it succeeds, reply with \"CHILD_WROTE\"\n\nI need to make sure the subagent prompt is self-contained and clear. The subagent doesn't share this conversation context, so I need to explain what tools it has available.\n\nLet me craft the prompt carefully."},{"type":"tool-call","id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4e48758d-52ba-437d-aa4a-8f0a9b02eb23"},"usage":{"inputTokens":5393,"outputTokens":418,"cacheReadTokens":0,"reasoningTokens":217}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387],"surfaceOp":"append"} -{"type":"tool/call","seq":389,"time":1784961244910,"data":{"turn":1,"step":1,"callId":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}} -{"type":"tool/result","seq":390,"time":1784961248973,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ZydZ4RS0mQwZaEcUr82s4359"},"content":[{"type":"tool-result","toolCallId":"call_00_ZydZ4RS0mQwZaEcUr82s4359","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"65c6c0b4-8566-4e90-9095-2f72c69ce227"}},"sourceEventSeqs":[389],"surfaceOp":"append"} -{"type":"step/end","seq":391,"time":1784961248981,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":392,"time":1784961248982,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":393,"time":1784961249963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":394,"time0":1784961249964,"data":{"turn":1,"step":2,"index":0,"dt":[107,31,1,0,32,0,0,0,1,0,33,0,0,1,0,0,33,1,0,0,0,0,36,1,0,0,29,1,33,1,0,0,0,34,2,0,1,0,32,1,0,0,32,1,0,0,0,34,0,1,0,0,0,35,0,1,0,0,0,31,1,0,0,0,0,33,1,0,0,1,0,32,0,1,0,0,32,1,0,32,1,0,1,0,32],"texts":["The"," sub","agent"," returned"," \"","CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","\"."," So"," the"," write"," was"," denied"," by"," the"," sand","box"," because"," it","'s"," in"," read","-only"," mode",".\n\n","I"," need"," to",":\n","1","."," State"," in"," one"," short"," sentence"," whether"," the"," child"," was"," denied"," by"," the"," sand","box",","," quoting"," the"," denial"," marker"," if"," there"," is"," one",".\n","2","."," Then"," reply"," with"," \"","PAR","ENT","_D","ONE","\".\n\n","Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":480,"time":1784961250679,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":481,"time0":1784961250679,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,33,1,0,0,0,1,32,1,0,0,0,0,32,1,0,1,0,0,33,1,0,0,1,36,1,1],"texts":["The"," child"," was"," denied"," by"," the"," sand","box"," –"," denial"," marker",":"," `","[","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","`.\n\n","PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":511,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_DENIED [sandbox: file access denied under read-only mode]\". So the write was denied by the sandbox because it's in read-only mode.\n\nI need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one.\n2. Then reply with \"PARENT_DONE\".\n\nLet me do that."}}}} -{"type":"assistant/chunk","seq":512,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}}}} -{"type":"assistant/chunk","seq":513,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":80,"outputTokens":117,"cacheReadTokens":5760,"reasoningTokens":86}}}} -{"type":"assistant/chunk","seq":514,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":515,"time":1784961250855,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The subagent returned \"CHILD_DENIED [sandbox: file access denied under read-only mode]\". So the write was denied by the sandbox because it's in read-only mode.\n\nI need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one.\n2. Then reply with \"PARENT_DONE\".\n\nLet me do that."},{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"258d0ae6-5800-4f06-8f3d-d9988b8dedde"},"usage":{"inputTokens":80,"outputTokens":117,"cacheReadTokens":5760,"reasoningTokens":86}},"sourceEventSeqs":[393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514],"surfaceOp":"append"} -{"type":"step/end","seq":516,"time":1784961250868,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":517,"time":1784961250869,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl deleted file mode 100644 index 6f0c6507ad..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md deleted file mode 100644 index e3437ad61a..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md +++ /dev/null @@ -1,24 +0,0 @@ -You are an AI agent powered by the DeepSeek Harness SDK. - -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - -Verify your work by running the code or tests. Keep answers brief and factual. - - -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - - - -Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." - } - }, - "required": [ - "command", - "description" - ] - } - }, - { - "name": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace. Must match exactly." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." - }, - "meta": { - "type": "object", - "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} From db0b43bf86b492999454b6163bb80f916c280d5e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:40:51 +0800 Subject: [PATCH 3/5] persistence: advance schemas after removing policy columns The simplification deletes sandbox_mode and approval_policy from the parent branch's SQLite layouts. Restoring master's older version numbers would violate the monotonic schema contract and could make a database created by the parent look current under a different layout. Advance durable session persistence from schema 11 to 12 and the disposable session-query index from 6 to 7. The former rejects the incompatible parent layout; the latter resets its derived tables through the existing version-mismatch path. JSONL shares SESSION_FORMAT_VERSION 0 during pre-release, so explicitly reject the retired sandboxMode and approvalPolicy header fields instead of silently dropping the only inherited policy facts from a parent-produced child log. New logs carry those facts as ordinary seeded events. Focused JSONL, SQLite persistence, and SQLite query suites cover all affected source lines and branches. --- .../session-persistence-jsonl/src/format.ts | 3 +++ .../session-persistence-jsonl/tests/jsonl.spec.ts | 6 ++++++ .../session-persistence-sqlite/src/schema.ts | 2 +- .../session-persistence-sqlite/tests/sqlite.spec.ts | 2 +- packages/session-query/session-query-sqlite/src/schema.ts | 2 +- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 03e8b15da0..f7d338877d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -64,6 +64,9 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { * @returns the header, absent optional fields omitted. */ export function fromHeaderLine(line: HeaderLine): SessionHeader { + if (Object.hasOwn(line, 'sandboxMode') || Object.hasOwn(line, 'approvalPolicy')) { + throw new Error('session header uses retired policy baseline fields') + } return { version: line.version, id: line.id, diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index f57b105a8b..5171bb8585 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1023,6 +1023,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toContain('big') }) + it.each(['sandboxMode', 'approvalPolicy'] as const)('rejects the retired %s header field', (field) => { + const line = { ...toHeaderLine(meta('retired-policy-header')), [field]: 'read-only' } + expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`))) + .toThrow(/retired policy baseline fields/) + }) + it('list rejects a header whose cwd does not identify its physical log', async () => { const m = meta('misplaced', '/stored') await ctx.sessionPersistence.create(m) diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..bdbd657e3e 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 = 10 +export const SCHEMA_VERSION = 12 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 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 82f00519ba..ddc46880cf 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -609,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(10) + expect(SCHEMA_VERSION).toBe(12) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..f5c2d81bcf 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 = 5 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 From 6d389d261ef0370d67cc709fde19eac886b4751e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:51:13 +0800 Subject: [PATCH 4/5] docs(policy): describe constructor-seeded inheritance The implementation no longer stores inherited policy in SessionHeader or resolves a second baseline chain, but the feature, sandbox, and approval notes still described that machinery. Keeping those claims would make the smaller design look incomplete and invite reintroduction of the generic persistence surface. Rewrite the owning feature note around the actual delegation snapshot: source-tagged policy events follow the optional fork prefix, ordinary last-event-wins folds establish precedence, and persistence captures the constructor seed with the first materialized batch. Condense the alternatives and consequences to the decisions and coverage that remain load-bearing. Align the sandbox and approval notes plus the subagent-inprocess consumer README with that contract. Update the four Chinese counterparts minimally and re-record each pairing hash so both languages describe the same shipped mechanism. --- .../2026-07-06-approval-seam.i18n.yaml | 6 ++-- .../feature/2026-07-06-approval-seam.md | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +-- .../implemented/feature/2026-07-06-sandbox.md | 6 ++-- .../feature/2026-07-06-sandbox.zh.md | 6 ++-- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +-- .../2026-07-25-subagent-policy-inheritance.md | 29 +++++++++---------- ...26-07-25-subagent-policy-inheritance.zh.md | 29 +++++++++---------- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 ++ .../subagent/subagent-inprocess/README.zh.md | 2 ++ 12 files changed, 45 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index ab3de720a7..51ddb7e70f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.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 -2026-07-06-approval-seam.md: 729a9cdc5b723c5ddcb13ef6452d7421823fb19f -2026-07-06-approval-seam.zh.md: c51739b96f3e2d5091767bdc07795866df449fa1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md +2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31 +2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 729a9cdc5b..efb4159d73 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. The session POLICY does cross the boundary: a `'never'` parent's children inherit `'never'` via a stamped override ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)), so they are told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index c51739b96f..9a4656f30a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。但会话策略确实会跨过这条边界:`'never'` 父级的子 agent 通过盖章写入的覆盖项继承 `'never'`([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md)),因此它们一开始就被告知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 541c1edf95..9311c6cac7 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: a93e18dac629d55925f9b8d4c621b8d35d386c2e -2026-07-06-sandbox.zh.md: 2ca89a3ea9bad4acd5a9206476c8fba58971ce19 +2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e +2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a93e18dac6..42b78ad834 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -89,12 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — #### Per-session modes: the session log as the store ``` -effective(session) = findLast(the session's OWN post-seed knob events)?.value - ?? the inherited SessionHeader baseline - ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log; the middle rung is the delegation baseline a subagent child's header carries. Restart immunity (resuming a session replays its log and restores its header, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver captures a delegating parent's effective override synchronously at delegation and carries it into each child's creation-time `SessionHeader` (`sandboxMode`/`approvalPolicy`), so a tightened parent binds spawn children, fork children, and grandchildren with no first-turn timing window ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)). +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 2ca89a3ea9..dfa3349e4d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -89,12 +89,10 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### 按会话模式:会话日志即存储 ``` -effective(session) = findLast(the session's OWN post-seed knob events)?.value - ?? the inherited SessionHeader baseline - ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件;中间层是 subagent 子 agent 的会话头所携带的委派基线。重启免疫(恢复会话时回放其日志并还原其会话头,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。隔离并不使委派成为逃生通道:进程内 subagent 驱动器在委派时同步捕获发起委派的父级的有效覆盖,并将其带入每个子 agent 创建时的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),因此收紧后的父级会约束 spawn 子 agent、fork 子 agent 与孙代 agent,且不存在任何第一轮次的时序窗口([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 8e7928f278..5a96140e0e 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: fcc91310399dd0bbaae3acc444fbf790ae4d0b3c -2026-07-25-subagent-policy-inheritance.zh.md: d06948dc34213e87300bf46ee5f1fc43d8f48fc7 +2026-07-25-subagent-policy-inheritance.md: ae87e7c688a53babca7bb0afc043dc8360871240 +2026-07-25-subagent-policy-inheritance.zh.md: 4e6a11e9cef5fc773b50aa32c477c97e2686c8d0 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index fcc9131039..ae87e7c688 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -6,33 +6,30 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) ## Problem -Session policy overrides are per-session log folds: the effective sandbox mode is `fold(session's sandbox/mode events) ?? deployment default` ([the sandbox Agent Note](2026-07-06-sandbox.md)), and the approval policy folds `approval/policy` the same way. In-process subagent children get a NEW session, so no override crossed the delegation boundary: a spawn child of a `read-only`-switched parent ran under the (possibly wider) deployment default — delegation was a bypass channel for a user's tightening — and a fork child inherited only whatever switch happened to sit inside its completed-turn seed, missing exactly the most common timing (the user switches while the agent is idle, so the switch lands after the last `turn/end` and outside the seed). A `'never'` (headless/CI) approval parent likewise minted children that fell back to a prompting default. The escalation hint a denied child sees ("the approval prompt asks the user") also promised a prompt no answerer would ever deliver. +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. ## Decision -The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the `delegationDepth` precedent: +The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. -- **Capture synchronously at delegation, persist in the creation-time header.** The driver reads `overrideOf(parent.session)` for both knobs 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, not the child — and stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. -- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — the pure `sandboxOverrideOf`/`approvalOverrideOf` exports, surfaced as service methods — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (`resolve()`, pty-local) and the permission presets (`current`/`set`), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (`ctx.get`, type-only imports, `peerDependenciesMeta.optional`): compositions without them delegate policy-free, unchanged. -- **Fork stale-seed precedence falls out of the seed boundary — scoped to delegation children.** A fork seed may carry the parent's OLD switch events; when a header baseline exists, `overrideOf` folds only events past `header.seedLength` — the baseline was captured from the parent's FULL log at delegation, so seed-carried history is subsumed by it while a switch the child makes ITSELF still outranks 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: there, seeded switches ARE the replayed inherited truth, and slicing them away would silently widen the child to the deployment default. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. -- **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, so they can never leak down the chain. +Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event in the child's constructor seed. The driver places these events after any fork prefix while keeping `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's already-seeded log, so the rule composes without another inheritance mechanism. + +Constructor seeds are validated before publication and captured by persistence when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch. ### What a blocked child experiences -A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by a controller that can answer — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation. +A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. ## Alternatives considered -- **Stamping the inherited override as `sandbox/mode`/`approval/policy` events inside the child's first turn (the shipped first iteration)** — superseded: it kept the log-as-store idiom with zero format changes, but review surfaced a durability hole the turn-enclosure contract cannot patch — an idle SessionStart-style injection persists a complete one-shot turn BEFORE any prompt turn opens, so a crash in that window leaves a resumable-looking child with no inherited policy, and no event anchor exists earlier (creation-time appends are crash-tail garbage, injection turns dispatch no waterfall, `session/event` listeners cannot re-append). The header baseline closes every timing window at once and deletes the listener/prepend/dedup machinery the event approach needed. -- **Stamping at child creation (outside any turn)** — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright. +- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Constructor-seeded events have the required ordering and reuse the existing durable store. +- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already accepts initial log events. +- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. -- **Forcing `approvalPolicy: 'never'` onto every in-process child** — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons. -- **Routing a child's approval asks to the root session's controller** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived. +- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). ## Consequences -- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). -- The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. -- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. -- Assembled-app snapshots pin both strengths. The recorded `subagent-sandbox-inheritance` ACP scenario proves a delegated child confined under a read-only DEPLOYMENT policy (the automation-only protocol has no session-scoped switch). The keyless `subagent-inheritance` headless scenario pins the parent-ONLY override on the semantic-checkpoint precedent: a seeded parent log carrying a real `sandbox/mode: read-only` switch under a workspace-write deployment default is resumed through the Loader-booted cli-demo app via a resume fixture plugin and delegates; the child's real write is denied by the real fence, its persisted header carries the inherited baseline, and disabling the driver's capture makes the scenario fail on the physical disk assertion — the assembled-app red/green anchor for the delegation bypass. -- `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, default omission, and context disposal. +- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. +- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index d06948dc34..4e6a11e9ce 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -6,33 +6,30 @@ Status: implemented ## 问题 -会话策略覆盖项是按会话的日志折叠:生效沙箱模式等于 `fold(session's sandbox/mode events) ?? deployment default`([沙箱 Agent Note](2026-07-06-sandbox.md)),审批策略以同样的方式折叠 `approval/policy`。进程内 subagent 的子 agent(智能体)拿到的是一个全新会话,因此没有任何覆盖项能跨过委派边界:父 agent 已切换到 `read-only` 时,其 spawn 子 agent 却运行在(可能更宽的)部署默认值之下,委派成了绕开用户收紧的旁路通道;fork 子 agent 只能继承恰好落在其已完成轮次种子内的切换,而恰恰漏掉最常见的时机(用户在 agent 空闲时切换,切换落在最后一个 `turn/end` 之后、种子之外)。审批策略为 `'never'`(无头/CI 场景)的父 agent,其创建出的子 agent 同样回退到了会向用户弹出提示的默认策略。被拒的子 agent 看到的升级提示文案(「审批提示会询问用户」)还承诺了一个永远不会有应答器送达的提示。 +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 ## 决策 -共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: +共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 -- **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 -- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——即纯函数导出 `sandboxOverrideOf`/`approvalOverrideOf`,以服务方法的形式暴露——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都无条件按自己的封闭词汇校验(即便自己做出的切换会遮蔽基线,损坏的会话头也会大声失败),遇到词汇之外的值即抛出异常。每一个旋钮消费方都经由同一条链解析——强制执行侧(`resolve()`、pty-local)与权限 preset(`current`/`set`)皆然——因此当选中更窄的 preset 时,继承了更宽基线的子 agent 得到的是真实的旋钮切换,而非静默的空操作。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入,`peerDependenciesMeta.optional`):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由种子边界自然得出——仅限委派子 agent。**fork 种子可能携带父级旧的切换事件;当会话头基线存在时,`overrideOf` 只折叠 `header.seedLength` 之后的事件——基线是在委派时从父级的完整日志捕获的,因此种子携带的历史已被它所涵盖,而子 agent 自己做出的切换仍然优先于基线。没有基线时(顶层会话,或未捕获任何策略元数据的通用 `SessionStore.fork` 子会话),折叠覆盖完整日志:此时种子携带的切换本身就是回放所得的继承事实,把它们切掉会把子会话静默放宽到部署默认值。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 -- **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 +每个捕获值都会成为子 agent 构造种子中的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。驱动器把这些事件放在任意 fork 前缀之后,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已预置事件的日志,因此无需另一套继承机制即可组合此规则。 + +构造种子在发布前经过校验,并在会话公布时由持久化层捕获。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。 ### 被拦住的子 agent 会经历什么 -受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由一个能够应答的控制方持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。 +受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会失败关闭,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 ## 考虑过的替代方案 -- **在子 agent 的第一个轮次内,把继承的覆盖项作为 `sandbox/mode`/`approval/policy` 事件盖章写入(已合入的第一版实现)**:已被取代。它保住了「日志即存储」的惯用法,零格式变更,但评审发现了一个轮次封闭契约无法修补的持久性漏洞:空闲时的 SessionStart 式注入会在任何提示词轮次开启之前就持久化一个完整的一次性轮次,在该窗口内崩溃会留下一个看似可恢复、却没有任何继承策略的子 agent,而且不存在更早的事件锚点(创建时的追加只是崩溃残留的尾部垃圾,注入轮次不派发任何 waterfall,`session/event` 监听器也无法重入追加)。会话头基线一举关闭所有时序窗口,并删除了事件方案所需的监听器/前置安装/去重机制。 -- **在子 agent 创建时(任何轮次之外)盖章**:不予采纳。持久化契约在轮次边界提交,因此轮次开始前的裸事件在重新加载时会被当作撕裂尾部截断;会话不变量测试套件会直接判这种追加失败。 +- **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。构造时预置的事件具备所需顺序,并复用现有持久化存储。 +- **首个提示词监听器**:不予采纳。尽管创建事务已经接受初始日志事件,它仍会引入监听器顺序与更晚的时序边界。 +- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 -- **给每个进程内子 agent 强制设置 `approvalPolicy: 'never'`**:不予采纳。这在今天是事实(没有应答器认领它们),但它会静默排除未来能够服务子 agent 的应答器,并搅浑继承语义;只继承父级的覆盖项既保住 fail-closed 结果,又让每次请求的拒绝原因保持诚实。 -- **把子 agent 的审批请求路由给根会话的控制方**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。 +- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 ## 后果 -- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 -- 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 -- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 -- 组装后应用的快照钉住两种强度。已录制的 `subagent-sandbox-inheritance` ACP 场景证明了一个被委派的子 agent 被约束在只读的部署级策略之下(这个仅面向自动化的协议没有会话作用域的切换)。无密钥的 `subagent-inheritance` headless 场景则沿语义检查点先例钉住仅父级的覆盖项:在 workspace-write 的部署默认值之下,预置一份携带真实 `sandbox/mode: read-only` 切换的父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它并发起委派;子 agent 的真实写入被真实围栏拒绝,其持久化的会话头携带继承来的基线,而禁用驱动器的捕获会让该场景在物理落盘断言上失败——这就是委派旁路在组装后应用层面的红/绿锚点。 -- `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、默认值省略与上下文释放。 +- 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 +- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 621b896045..402f3a6684 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: 3606799e6d16e80473006f82b834a10953270914 -README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15 +README.md: 02d9bd7d2dc792055a13d51402572313855ff1ff +README.zh.md: f55467b37a948ef5a0a34bd45d8a11d7a004ff29 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 3606799e6d..02d9bd7d2d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,6 +18,8 @@ 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. +When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and seeds a source-tagged event after any fork prefix. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.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 7f52d3699d..f55467b37a 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,6 +18,8 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在任意 fork 前缀之后预置一条带来源标记的事件。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 + ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 From cc6e5c717390ec52eea92cbb866f5219f06c6ff0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:54:15 +0800 Subject: [PATCH 5/5] docs(session): catalog creation-time seed facts The simplified inheritance path uses Session creation seeds for policy events, and the public CreateSessionOptions JSDoc now names that supported role. The type-equivalent persistence catalog still described seeds as replay/fork-only, so doc-sync correctly rejected the mismatch. Align the explanatory paragraph and exact type-equivalent block in both languages, then re-record the bilingual pair. This keeps the public catalog from understating the constructor seam that makes the simplification possible. --- docs/core-data-structures/persistence.i18n.yaml | 4 ++-- docs/core-data-structures/persistence.md | 4 ++-- docs/core-data-structures/persistence.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ea83035f92..a89ea097e6 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: 5a660e17d6f498213564ca7d68dc4d7a615ba1de -persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 +persistence.md: 94813764bea1b87856d5c7cfc86b568df6408c68 +persistence.zh.md: fe5d497a58502356a0be56eec38edbfdbcf903c1 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 5a660e17d6..94813764be 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `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`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (initial events for replay, fork, or creation-time facts) 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 /** @@ -86,7 +86,7 @@ Creating a `Session` through the store takes a `seed` (replay/fork an existing e * store folds into a {@link SessionHeader}. */ 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 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index b5477cfc82..fe5d497a58 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(用于回放、fork 或创建时事实的初始事件)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -86,7 +86,7 @@ interface SessionHeader { * store folds into a {@link SessionHeader}. */ 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