From e4256a1684b23de0eaa9190e6841eab1e9690718 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 21:34:38 +0800 Subject: [PATCH 01/62] feat(web): steer the whole queue with an empty-draft Cmd/Ctrl+Enter --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 6 + .../2026-08-06-web-queue-steer-all-gesture.md | 30 +++++ ...26-08-06-web-queue-steer-all-gesture.zh.md | 30 +++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/input/contract.ts | 6 + .../src/client/input/facade.ts | 15 +++ .../ui-conversation/src/client/input/hub.ts | 33 +++++- .../src/client/skeleton/InputBar.tsx | 13 ++- .../ui-conversation/tests/input-bar.spec.tsx | 104 ++++++++++++++++-- .../tests/service-orchestration.spec.ts | 78 ++++++++++++- 13 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml new file mode 100644 index 0000000000..5b51304fe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc +2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md new file mode 100644 index 0000000000..e65089fca7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -0,0 +1,30 @@ +# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter + +Status: implemented + +English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md) + +## Problem + +While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything". + +## Decision + +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. + +The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. + +## Consequences + +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. + +## Related + +The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision. + +## Alternatives considered + +- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op. +- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. +- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. +- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md new file mode 100644 index 0000000000..abccf60a9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话 + +Status: implemented + +[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文 + +## Problem + +主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。 + +## Decision + +空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 + +该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 + +## Consequences + +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 + +## Related + +逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。 + +## Alternatives considered + +- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。 +- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 +- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 +- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index be53206dde..73b3204d61 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: bbd115eac0eb914914dc11e504639633c801abdd -README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc +README.md: e7b57e25208a402a81284f930644bfc4208c9291 +README.zh.md: 1b8543fa24d8e8a62f6b8995619548928ab80ce1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bbd115eac0..e7b57e2520 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 843b49e311..1b8543fa24 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..0cdb4b9939 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -131,7 +131,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx) + const inputHub = new InputHub(ctx, t) // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 6af78bdb72..bc4b365373 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -88,6 +88,12 @@ export interface ComposerKeyboard { setDraft(text: string, editRange?: EditRange): void /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture; the queue dock's per-row steer + * button is the same operation applied to the whole queue). + */ + steerQueue(): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79516d04fa..fdceebd282 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,6 +39,11 @@ export interface SessionInputDeps { popup?: (() => PopupDismissFace | undefined) | undefined /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order (the empty-draft accelerated-Enter gesture); absent = unsupported. + */ + steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, mode: InputSubmitMode): void } @@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput { return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' } + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture). Execution belongs to the hub's + * queue choreography; absent dep = the gesture falls back to the machine's + * empty-draft no-op. + */ + steerQueue(): void { + this.deps.steerQueue?.() + } + /** * Space adjudication over the controller's hot state. * @returns true = a claim/insert was applied — the caller preventDefaults. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 560999aa21..e9e58035d2 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -10,6 +10,7 @@ */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -25,8 +26,14 @@ interface CommandFace { export class InputHub implements InputService { private readonly shells = new Map() - /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ - constructor(private readonly rootCtx: ClientContext) {} + /** + * @param ctx - client root context (services resolved lazily per call — boot order stays free). + * @param t - conversation-namespace translate thunk (reads the active locale at call time). + */ + constructor( + private readonly rootCtx: ClientContext, + private readonly t: TranslateNS<'conversation'>, + ) {} /** * Resolve the facade for one session-scope ctx (InputService face). @@ -58,6 +65,7 @@ export class InputHub implements InputService { popup: () => this.popup(actx), queue: queueReadFaceOf(session), defaultSink: (text, mode) => { this.sink(session, text, mode) }, + steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -139,6 +147,27 @@ export class InputHub implements InputService { ) } + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order — the same strict-steer operation as the queue dock's per-row + * button. A turn closing mid-way (`steer-unavailable`) or a row already + * claimed by the agent (`queue-item-not-found`) converges silently, while a + * genuine failure surfaces as one composer notice. + * @param session - the addressed host session. + * @param shell - the resident shell (notice outlet). + */ + private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { + const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + if (queued.length === 0) return + for (const item of queued) { + const result = await session.updateQueue(item.id, { kind: 'steer' }) + if (result.ok) continue + if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + shell.notify('error', this.t('queue.steerFailed')) + return + } + } + private controller(actx: ClientContext): SlashController | undefined { const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7a85ef7e08 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -247,9 +247,20 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return + const accelerated = e.ctrlKey || e.metaKey + // Empty-draft accelerated Enter acts on the queue instead of the (empty) + // draft: the machine rejects empty drafts, so the gesture steers every + // still-pending queued message into the running turn (the dock's per-row + // steer button applied to the whole queue). Steering needs the same + // window as the per-row button: a running ordinary session. + if (accelerated && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued')) { + keyboard.steerQueue() + return + } keyboard.submit(resolveSubmitMode( running, - e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + accelerated ? 'accelerated' : 'enter', subagent === null, )) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..67a9b48e90 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,6 +56,10 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean promptError?: ConversationSnapshot['promptError'] + /** Authoritative queue rows served to the machine overlay (empty = none). */ + queue?: ConversationSnapshot['queue'] + /** The hub's steer-all face (empty-draft accelerated Enter). */ + steerQueue?: () => void variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] @@ -69,14 +73,34 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } +/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ +function row(id: string): ConversationSnapshot['queue'][number] { + return { + id: id as never, messageId: `message-${id}` as never, placement: 'queued', + content: [{ type: 'text', text: id }], preview: id, text: id, + } +} + /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { const sink = vi.fn() const lex = over?.lexicon + const session = createSnapshotStore(snapshotOf({ + running: over?.running ?? false, + subagent: over?.subagent ?? null, + removed: over?.disabled ?? false, + promptError: over?.promptError ?? null, + queue: over?.queue ?? [], + })) type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + queue: { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + }, + ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined @@ -88,12 +112,6 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - const session = createSnapshotStore(snapshotOf({ - running: over?.running ?? false, - subagent: over?.subagent ?? null, - removed: over?.disabled ?? false, - promptError: over?.promptError ?? null, - })) const stop = vi.fn() const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] @@ -147,7 +165,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue } } describe('Enter semantics', () => { @@ -190,6 +208,78 @@ describe('Enter semantics', () => { expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) + it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { + const steerQueue = vi.fn() + const queue = [row('q-1'), row('q-2')] + const meta = bench({ running: true, queue, steerQueue }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.steerQueue).toHaveBeenCalledTimes(1) + expect(meta.sink).not.toHaveBeenCalled() + + const ctrl = bench({ running: true, queue, steerQueue: vi.fn() }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.steerQueue).toHaveBeenCalledTimes(1) + expect(ctrl.sink).not.toHaveBeenCalled() + }) + + it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + // Idle: the gesture falls through to the machine's empty-draft no-op. + const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.steerQueue).not.toHaveBeenCalled() + expect(idle.sink).not.toHaveBeenCalled() + + // Plain Enter never steers the queue, even under the busy Steer preference. + const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.steerQueue).not.toHaveBeenCalled() + expect(plain.sink).not.toHaveBeenCalled() + + // Subagent sessions keep the queue transport (no steering face). + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true }) + expect(child.steerQueue).not.toHaveBeenCalled() + expect(child.sink).not.toHaveBeenCalled() + + // No queued rows: the empty draft stays a no-op. + const none = bench({ running: true, steerQueue: vi.fn() }) + fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true }) + expect(none.steerQueue).not.toHaveBeenCalled() + expect(none.sink).not.toHaveBeenCalled() + + // Pending steering rows are not the queue: nothing to flush. + const steering = bench({ + running: true, + queue: [{ ...row('s-1'), placement: 'steering' }], + steerQueue: vi.fn(), + }) + fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) + expect(steering.steerQueue).not.toHaveBeenCalled() + expect(steering.sink).not.toHaveBeenCalled() + }) + + it('draft content outranks the queue: accelerated Enter steers the draft only', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) + expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(steerQueue).not.toHaveBeenCalled() + }) + + it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => { + const { textarea, sink } = bench({ running: true, queue: [row('q-1')] }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(sink).not.toHaveBeenCalled() + }) + it('platform undo/redo chords route to the machine, never the browser stack', () => { const { textarea, shell } = bench({ draft: '' }) fireEvent.change(textarea, { target: { value: 'first' } }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index ebd51f8408..126cb65a47 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,8 +6,11 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import { InputHub } from '../src/client/input/hub.ts' +import { zh } from '../src/client/locales.ts' async function bench() { const runtime = await SlotTestRuntime.create() @@ -21,13 +24,13 @@ async function bench() { }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. - const fiber = runtime.ctx.plugin(ConversationService, { - input: new InputHub(runtime.ctx), - }) + const hub = new InputHub(runtime.ctx, makeTranslate(zh, {})) + const fiber = runtime.ctx.plugin(ConversationService, { input: hub }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } + const shell = hub.shellFor(runtime.sessions.binding('s1')!) + return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -85,9 +88,74 @@ describe('ConversationService', () => { // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() await bare.plugin(ConversationService, { - input: new InputHub(bare), + input: new InputHub(bare, makeTranslate(zh, {})), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) + +describe('InputHub queue steering (empty-draft accelerated Enter)', () => { + const row = (id: string): QueuedMessage => ({ + id: id as never, + messageId: `message-${id}` as never, + placement: 'queued', + content: [{ type: 'text', text: id }], + preview: id, + text: id, + }) + + it('steers every queued row in FIFO order and leaves steering rows alone', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] + }) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.updateQueue).toHaveBeenCalledTimes(2) + }) + expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' }) + expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('converges silently when the turn closes or a row is claimed mid-steer', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('surfaces one notice on a genuine steer failure and stops', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.shell.notices.getSnapshot()).toEqual( + expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }), + ) + }) + expect(b.updateQueue).toHaveBeenCalledTimes(1) + await b.runtime.dispose() + }) + + it('no-ops without queued rows', async () => { + const b = await bench() + b.shell.steerQueue() + expect(b.updateQueue).not.toHaveBeenCalled() + await b.runtime.dispose() + }) +}) From 8ff75622c601ab156f59aac70fd6a86d31d7b4f6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 00:15:55 +0800 Subject: [PATCH 02/62] feat(bundle): default Windows hosts to the pwsh shell stack win32 hosts booting a shipped profile now get pwsh-local as the ctx.bash executor and tool-pwsh as the shell tool through the base bundle's new windows.cordis.patch.yml platform layer, injected by the launcher between the bundle layers and the user layers on win32. bash-sandbox, tool-bash, permission, and ui-permission are disabled there: the POSIX-only executor cannot run on Windows, and dsh-permission requires a confining executor. Overriding the default is a composition decision through the user's cordis.patch.yml; there is no environment override channel. apps/cli and dsh-base re-declare dsh-pwsh-local/dsh-tool-pwsh so the profile module fallback links them for cold starts (the profiles rework had dropped them from the CLI closure). Promotes the windows-pwsh-default Agent Note from proposed to implemented and documents the platform layer in the base bundle README. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 2 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 ++ .../2026-08-01-windows-pwsh-default.md | 42 ++++++++++++ .../2026-08-01-windows-pwsh-default.zh.md | 42 ++++++++++++ .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 -- .../2026-08-01-windows-pwsh-default.md | 39 ----------- .../2026-08-01-windows-pwsh-default.zh.md | 39 ----------- apps/cli/package.json | 2 + apps/cli/src/dump-config.ts | 7 ++ apps/cli/src/profile-boot.ts | 28 +++++--- apps/cli/src/windows-shell.ts | 55 ++++++++++++++++ apps/cli/tests/windows-shell.spec.ts | 64 +++++++++++++++++++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 5 +- packages/bundle/base/README.zh.md | 5 +- packages/bundle/base/package.json | 4 ++ packages/bundle/base/windows.cordis.patch.yml | 34 ++++++++++ pnpm-lock.yaml | 12 ++++ 23 files changed, 306 insertions(+), 104 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md create mode 100644 apps/cli/src/windows-shell.ts create mode 100644 apps/cli/tests/windows-shell.spec.ts create mode 100644 packages/bundle/base/windows.cordis.patch.yml diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index d4ec255235..efd7d56dae 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.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-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 -2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c +2026-08-01-pwsh-tool-and-executor.md: 77f0a13d0efa55c91b4e58ee2474ac6877947c80 +2026-08-01-pwsh-tool-and-executor.zh.md: 95c0d8f087f11d192350cf92ed866bb8a4ea33b4 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 7206f8ffe6..77f0a13d0e 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -17,7 +17,7 @@ Two new packages under `packages/bash/`: Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. -The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index 5a48adb79f..95c0d8f087 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -17,7 +17,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 -本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..e26d1ca354 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: 1fa30ef757ccb81544479a08887355839fb86d46 +2026-08-01-windows-pwsh-default.zh.md: c6e1361dba29cff8340ef6e26bf071f81544642b diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..1fa30ef757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,42 @@ +# Agent Note: Windows defaults to pwsh + +Status: implemented + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell. + +## Decision + +Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. + +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool), disables `permission`/`ui-permission` (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined pwsh executor cannot honor; see its constructor guard — and the client knob would advertise a shell it cannot enforce), and inserts `pwsh-local`/`tool-pwsh`. The fs tools keep the sandbox policy and approval service, so file confinement and escalation still apply on Windows. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. + +The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. + +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. File confinement keeps working through the fs stack. + +**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. + +## Consequences + +- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. +- The permission switcher leaves the Windows roster; session permission facts pin the composition defaults through the approval service and sandbox policy. + +## Verification + +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected. +- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..c6e1361dba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Windows 默认改用 pwsh + +Status: implemented + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义);面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。 + +## 决策 + +启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 + +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)、禁用 `permission`/`ui-permission`(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制 pwsh 执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个它无法强制执行的 shell),并插入 `pwsh-local`/`tool-pwsh`。fs 工具保留 sandbox 策略与批准服务,因此 Windows 上的文件限制与升级仍然生效。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 + +原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 + +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。文件限制继续经由 fs 栈生效。 + +**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 + +## 后果 + +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 +- 权限切换器离开 Windows 清单;会话权限事实通过批准服务与 sandbox 策略固定组合默认值。 + +## 验证 + +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入。 +- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..204560cfb5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 23693c833a779da7a0f52ab1c959bf7eb8649310 +2026-08-05-pwsh-ui-bash-parity.zh.md: ef44e9ff043c0a533d2cc34bb776c6eb3f0566f8 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..23693c833a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..ef44e9ff04 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml deleted file mode 100644 index fa7ea8e141..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 1c3ccef23bb5cd9bc37237bd69aac2e2c56649a8 -2026-08-01-windows-pwsh-default.zh.md: 3958d21eb8a9d306009b11d6e9806a1654a8958e diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md deleted file mode 100644 index 1c3ccef23b..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows defaults to pwsh (roadmap) - -Status: proposed - -English | [中文](2026-08-01-windows-pwsh-default.zh.md) - -## Problem - -The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. - -## Proposal - -Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. - -1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. - -The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior. - -## Alternatives considered - -**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. - -**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. - -**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. - -## Acceptance criteria - -- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. -- POSIX hosts are byte-for-byte unaffected (same roster, same executor). -- The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). - -## Risks - -- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. -- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md deleted file mode 100644 index 3958d21eb8..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows 默认改用 pwsh(路线图) - -Status: proposed - -[English](2026-08-01-windows-pwsh-default.md) | 中文 - -## 问题 - -harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 - -## 提案 - -两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 - -1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **pwsh GUI 渲染**——Web 表面以 bash 形状的终端呈现渲染 pwsh 调用(带退出状态 pill 的 terminal 卡),即 bash 终端卡片的对应物。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有终端孪生。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍无人认领。 - -各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。 - -## 备选方案 - -**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 - -**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 - -**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 - -## 验收标准 - -- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 -- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 -- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除让终端表面无快照可做)。 - -## 风险 - -- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 -- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 -- **渲染约定**——bash 形状的终端孪生已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍是带快照表面的 UI 设计决策,随阶段 1 一起延期。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..11078458e6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,11 +24,13 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 9de7a55f60..29605878de 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,6 +15,7 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) + // The win32 shell platform layer rides between bundles and user layers, + // exactly where the boot applies it. + const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) + if (windowsShellLayer !== undefined) { + layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) + } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b69a938213..7115f1208a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -27,6 +27,7 @@ import { import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -103,6 +104,8 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] + /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ + windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ @@ -117,16 +120,23 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags] + return [ + ...composed.bundlePatches, + ...composed.windowsShellPatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlayAndFlags, + ] } /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer - * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to - * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * `dsh.profile.bundles` order, the win32 shell platform layer (when the host + * is Windows), the + * profile's user layer, the home-level user layer (`$DSH_HOME/cordis.patch.yml` + * — machine-local preferences that apply to every profile, so it outranks the + * per-profile layer), `--patch` overlays, then flag patches derived from the + * composed rows, then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. @@ -141,14 +151,15 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlayAndFlags, rows } + return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } } /** Options for {@link runProfile}. */ @@ -215,6 +226,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, + ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlayAndFlags, diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts new file mode 100644 index 0000000000..425699ef4f --- /dev/null +++ b/apps/cli/src/windows-shell.ts @@ -0,0 +1,55 @@ +/** + * The Windows shell platform layer: on win32 hosts the shipped profile + * compositions swap the POSIX-only bash stack for the PowerShell stack + * (`@deepseek-ai/dsh-pwsh-local` + `@deepseek-ai/dsh-tool-pwsh`), matching + * the Windows-pwsh-default roadmap. The layer is the base bundle's + * `windows.cordis.patch.yml`, injected by the launcher between the bundle + * layers and the user layers so a user patch can still override it — the + * only override channel is composition config, like every other roster + * decision. POSIX hosts never receive the layer. + * @module @deepseek-ai/dsh/windows-shell + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' + +/** The base bundle whose package carries the Windows shell patch. */ +export const BASE_BUNDLE = '@deepseek-ai/dsh-base' + +/** The Windows shell patch filename inside the base bundle package. */ +export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' + +/** One Windows shell platform layer: its patch file and parsed patches. */ +export interface WindowsShellLayer { + /** The patch file path, used as the config-dump provenance label. */ + label: string + /** The parsed patch entries, applied after the bundle layers. */ + patches: PatchOptions[] +} + +/** + * Resolve the Windows shell platform layer for a profile composition. + * @param platform - the host platform (`process.platform` at call sites). + * @param layers - the profile's bundle layers, in application order. + * @param binName - the diagnostic prefix on thrown errors (`dsh`). + * @returns the pwsh layer on win32, else `undefined`. A custom profile that + * mounts no base bundle is skipped (it owns its shell stack); a base + * bundle that ships no Windows shell patch fails loud — the shipped + * package always carries it, so a miss is a broken installation. + */ +export function resolveWindowsShellLayer( + platform: NodeJS.Platform, + layers: readonly ProfileLayer[], + binName: string, +): WindowsShellLayer | undefined { + if (platform !== 'win32') return undefined + const base = layers.find(layer => layer.packageName === BASE_BUNDLE) + if (base === undefined) return undefined + const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) + if (!existsSync(label)) { + throw new Error(`${binName}: ${BASE_BUNDLE} ships no ${WINDOWS_SHELL_PATCH_FILENAME}`) + } + return { label, patches: loadOverlayPatches(binName, label) } +} diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts new file mode 100644 index 0000000000..f0cfddbaae --- /dev/null +++ b/apps/cli/tests/windows-shell.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { + BASE_BUNDLE, + resolveWindowsShellLayer, + WINDOWS_SHELL_PATCH_FILENAME, +} from '../src/windows-shell.ts' + +const WINDOWS_PATCH = `- id: bash-sandbox + disabled: true +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' +` + +/** One fake bundle layer rooted in a temp directory. */ +function fakeLayer(packageName: string, dir: string): ProfileLayer { + return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } +} + +/** A base bundle layer whose package carries the Windows shell patch. */ +function baseLayerWithPatch(dir: string): ProfileLayer { + writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) + return fakeLayer(BASE_BUNDLE, dir) +} + +describe('resolveWindowsShellLayer', () => { + let base: string + afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) + const tempBase = (): string => { + base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) + return base + } + + it('never applies on POSIX hosts', () => { + expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + }) + + it('defaults Windows hosts to the pwsh platform layer', () => { + const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') + expect(layer).toBeDefined() + expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) + expect(layer?.patches).toEqual([ + { id: 'bash-sandbox', disabled: true }, + { insert: [{ id: 'pwsh-local', name: '@deepseek-ai/dsh-pwsh-local' }] }, + ]) + }) + + it('skips custom profiles without a base bundle', () => { + const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) + expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() + }) + + it('fails loud when the base bundle ships no Windows shell patch', () => { + const base = tempBase() + mkdirSync(base, { recursive: true }) + expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) + .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) + }) +}) diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 2ae7df0bdc..a47ac9cbc7 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 301d397d4c87687b382665cf63af47ab5e3f85be -README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7 +README.md: a4f220c956f7f67d7810827d4488d54a071ad3d6 +README.zh.md: eaf96f8c0640481163e7a1c1533a07e35ecc294f diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 301d397d4c..a4f220c956 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. + +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash executor/tool and the permission stack (dsh-permission requires a confining executor), and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`). The launcher applies it between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Windows loses the permission switcher** — `dsh-permission` hard-requires a confining `ctx.bash` executor, so the Windows platform layer disables `permission`/`ui-permission` with the bash stack. The fs tools keep the sandbox policy and the approval service, so file confinement and escalation still apply on Windows. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index f007bc817b..eaf96f8c06 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 + +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 执行器/工具与权限栈(dsh-permission 要求有限权能力的执行器),并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -17,3 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Windows 上失去权限切换器**:`dsh-permission` 硬性要求有限权能力的 `ctx.bash` 执行器,因此 Windows 平台层随 bash 栈一起禁用 `permission`/`ui-permission`。fs 工具保留 sandbox 策略与批准服务,Windows 上的文件限制与升级仍然生效。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 95e169cabb..cb2b7178f7 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -16,6 +16,7 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -23,6 +24,7 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", + "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +85,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml new file mode 100644 index 0000000000..59d5e81582 --- /dev/null +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -0,0 +1,34 @@ +# The dsh-base Windows platform layer: applied by the dsh launcher on win32 +# hosts, between the bundle layers and the user layers, replacing the +# POSIX-only bash stack with the PowerShell stack. The launcher reads THIS +# file from the base bundle package (never through dsh.bundle.patch — that +# field names the one universal layer). A Windows host that prefers bash +# overrides the rows here through its profile or home cordis.patch.yml. +# +# Windows hosts cannot run the shipped bash executor (POSIX-only: hardcoded +# `bash -c` argv and process-group semantics), so the shipped Windows +# experience is PowerShell-native: pwsh-local backs `ctx.bash` and tool-pwsh +# is the model-facing shell tool. dsh-permission requires a confining +# executor (its presets bundle a sandbox mode the unconfined pwsh executor +# cannot honor), so the permission service and its client knob leave the +# Windows roster with the bash stack; the fs tools keep the sandbox policy +# and the approval service, so file confinement and escalation still apply. + +- id: bash-sandbox + disabled: true + +- id: tool-bash + disabled: true + +- id: permission + disabled: true + +- id: ui-permission + disabled: true + +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b826046bea..453109bcae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference @@ -176,6 +179,9 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app @@ -922,6 +928,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard @@ -1006,6 +1015,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../workflow/tool-ralph From acac2149a5f8b3cbbbd26cd848ef73ac8792e7d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 01:20:40 +0800 Subject: [PATCH 03/62] feat(bundle): degrade the Windows shell layer to danger-full-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows platform layer previously kept fs path-rule confinement (sandbox-policy + fs-sandbox) next to the unconfined pwsh shell. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shell can bypass fs-only path rules with one command — the policy was theater. The layer now removes the whole sandbox stack (sandbox, sandbox-policy, fs-sandbox disabled), mounts the unconfined dsh-fs-local, and degrades to danger-full-access: permission/ui-permission leave the roster and the approval policy is 'never'. dsh-base declares dsh-fs-local so the profile module fallback links it for cold starts; base.spec.ts pins the shipped Windows roster (disables, inserts, approval policy); the Agent Note records the rejected fs-only confinement alternative. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 14 ++--- .../2026-08-01-windows-pwsh-default.zh.md | 14 ++--- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 4 +- packages/bundle/base/README.zh.md | 4 +- packages/bundle/base/package.json | 1 + packages/bundle/base/tests/base.spec.ts | 53 +++++++++++++++++-- packages/bundle/base/windows.cordis.patch.yml | 42 ++++++++++----- 9 files changed, 103 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index e26d1ca354..711699aa0b 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 1fa30ef757ccb81544479a08887355839fb86d46 -2026-08-01-windows-pwsh-default.zh.md: c6e1361dba29cff8340ef6e26bf071f81544642b +2026-08-01-windows-pwsh-default.md: ceffd825c2316841dfbe01f4510243ba8748e500 +2026-08-01-windows-pwsh-default.zh.md: ea8058878fe268bdf1317620c853b8279c543532 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 1fa30ef757..ceffd825c2 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,9 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool), disables `permission`/`ui-permission` (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined pwsh executor cannot honor; see its constructor guard — and the client knob would advertise a shell it cannot enforce), and inserts `pwsh-local`/`tool-pwsh`. The fs tools keep the sandbox policy and approval service, so file confinement and escalation still apply on Windows. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` policy is `never`. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -24,19 +24,21 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w **Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. -**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. File confinement keeps working through the fs stack. +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. + +**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access. **Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval policy is `never`, and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. - Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. -- The permission switcher leaves the Windows roster; session permission facts pin the composition defaults through the approval service and sandbox policy. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected. +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the `never` approval policy). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index c6e1361dba..ea8058878f 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,9 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)、禁用 `permission`/`ui-permission`(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制 pwsh 执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个它无法强制执行的 shell),并插入 `pwsh-local`/`tool-pwsh`。fs 工具保留 sandbox 策略与批准服务,因此 Windows 上的文件限制与升级仍然生效。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 策略为 `never`。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -24,19 +24,21 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 **从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 -**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。文件限制继续经由 fs 栈生效。 +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。 + +**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决:shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。 **交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 策略为 `never`、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 - 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 -- 权限切换器离开 Windows 清单;会话权限事实通过批准服务与 sandbox 策略固定组合默认值。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与 `never` approval 策略)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index a47ac9cbc7..0cc7f47a1b 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: a4f220c956f7f67d7810827d4488d54a071ad3d6 -README.zh.md: eaf96f8c0640481163e7a1c1533a07e35ecc294f +README.md: 81932442fe9e2edac82bda88bbe28ce56fd8b6c1 +README.zh.md: b0af307b7655a8729a8face324a2750e2e480651 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index a4f220c956..81932442fe 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash executor/tool and the permission stack (dsh-permission requires a confining executor), and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`). The launcher applies it between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), and the sandbox/fs-policy stack — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`, with the approval policy set to `never`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows loses the permission switcher** — `dsh-permission` hard-requires a confining `ctx.bash` executor, so the Windows platform layer disables `permission`/`ui-permission` with the bash stack. The fs tools keep the sandbox policy and the approval service, so file confinement and escalation still apply on Windows. +- **Windows has no sandbox** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack: `sandbox`/`sandbox-policy`/`fs-sandbox` are disabled, `dsh-fs-local` provides `ctx.fs`, the permission switcher leaves the roster, and the approval policy is `never`. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index eaf96f8c06..b0af307b76 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 执行器/工具与权限栈(dsh-permission 要求有限权能力的执行器),并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)与 sandbox/fs 策略栈——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`,`approval` 策略设为 `never`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上失去权限切换器**:`dsh-permission` 硬性要求有限权能力的 `ctx.bash` 执行器,因此 Windows 平台层随 bash 栈一起禁用 `permission`/`ui-permission`。fs 工具保留 sandbox 策略与批准服务,Windows 上的文件限制与升级仍然生效。 +- **Windows 上没有沙箱**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`,权限切换器离开清单,`approval` 策略为 `never`。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index cb2b7178f7..d2f93e2bd3 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 24ee2a1ba3..6adcab1715 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -13,13 +13,60 @@ import { entryListSchema } from '@cordisjs/plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + const manifest = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8'), + ) as { dsh?: { bundle?: { patch?: string } } } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) + const parsed = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. - const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) + const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap( + patch => patch.insert ?? [], + ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) + + it('ships the Windows platform layer as the documented danger-full-access roster', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const parsed = yaml.load( + readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), + { schema: entryListSchema }, + ) as { + id?: string + disabled?: boolean + insert?: { id?: string; name?: string }[] + config?: { policy?: string } + }[] + const disables = parsed + .filter(patch => patch.disabled === true) + .map(patch => patch.id) + // The POSIX-only sandboxed stacks leave the Windows roster as one unit: + // shell (bash-sandbox/tool-bash), the permission switcher it requires, + // and the fs/sandbox policy stack whose OS runners do not exist on win32. + expect(disables).toEqual( + expect.arrayContaining([ + 'bash-sandbox', + 'tool-bash', + 'permission', + 'ui-permission', + 'sandbox', + 'sandbox-policy', + 'fs-sandbox', + ]), + ) + const inserted = parsed + .flatMap(patch => patch.insert ?? []) + .map(row => row.id) + expect(inserted).toEqual( + expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), + ) + // Full danger-full-access degradation: no approval prompts on Windows. + expect(parsed.find(patch => patch.id === 'approval')?.config).toEqual({ + policy: 'never', + }) + }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 59d5e81582..50dbe8d9e5 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,18 +1,16 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers, replacing the -# POSIX-only bash stack with the PowerShell stack. The launcher reads THIS -# file from the base bundle package (never through dsh.bundle.patch — that -# field names the one universal layer). A Windows host that prefers bash -# overrides the rows here through its profile or home cordis.patch.yml. -# -# Windows hosts cannot run the shipped bash executor (POSIX-only: hardcoded -# `bash -c` argv and process-group semantics), so the shipped Windows -# experience is PowerShell-native: pwsh-local backs `ctx.bash` and tool-pwsh -# is the model-facing shell tool. dsh-permission requires a confining -# executor (its presets bundle a sandbox mode the unconfined pwsh executor -# cannot honor), so the permission service and its client knob leave the -# Windows roster with the bash stack; the fs tools keep the sandbox policy -# and the approval service, so file confinement and escalation still apply. +# hosts, between the bundle layers and the user layers. Windows cannot run +# the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack +# for the PowerShell stack AND drops the sandbox entirely: no OS runner +# exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any policy +# would be theater — the unconfined shell could bypass fs-only path rules +# with one command. Windows therefore degrades to danger-full-access: +# unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher +# (dsh-permission requires a confining executor), approval policy `never`. +# The launcher reads THIS file from the base bundle package (never through +# dsh.bundle.patch — that field names the one universal layer). A Windows +# host that prefers bash or confinement overrides these rows through its +# profile or home cordis.patch.yml. - id: bash-sandbox disabled: true @@ -26,9 +24,25 @@ - id: ui-permission disabled: true +- id: sandbox + disabled: true + +- id: sandbox-policy + disabled: true + +- id: fs-sandbox + disabled: true + +- id: approval + config: + policy: never + - insert: - id: pwsh-local name: '@deepseek-ai/dsh-pwsh-local' - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' + + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' From 73c6f351714762ae57f10317ff7687da6bbe0b7e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 01:41:32 +0800 Subject: [PATCH 04/62] fix(bundle): drop the approval service from the Windows layer entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows layer previously kept the approval service with policy 'never', which made the approval plugin inject 'Approval prompts are disabled in this session: actions that require approval are rejected automatically' into the model context. On Windows nothing asks for approval — the escalation surfaces (sandbox_permissions) do not exist — so the sentence described a rejection surface that is not there. The layer now disables the approval row too: the service is absent, the model is never told approval exists, and the danger-full-access degradation is complete. base.spec.ts pins approval among the Windows disables; the Agent Note and bundle README record the absent service. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../feature/2026-08-01-windows-pwsh-default.md | 6 +++--- .../feature/2026-08-01-windows-pwsh-default.zh.md | 6 +++--- packages/bundle/base/README.i18n.yaml | 4 ++-- packages/bundle/base/README.md | 4 ++-- packages/bundle/base/README.zh.md | 4 ++-- packages/bundle/base/tests/base.spec.ts | 11 ++++++----- packages/bundle/base/windows.cordis.patch.yml | 15 ++++++++------- pnpm-lock.yaml | 3 +++ 9 files changed, 31 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 711699aa0b..7ab92d8199 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: ceffd825c2316841dfbe01f4510243ba8748e500 -2026-08-01-windows-pwsh-default.zh.md: ea8058878fe268bdf1317620c853b8279c543532 +2026-08-01-windows-pwsh-default.md: 75a27b5140abacf77b5f20028752a4903a50eaec +2026-08-01-windows-pwsh-default.zh.md: 8b940f1973835f8143bde05b37eba6f00e27f8f4 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index ceffd825c2..75a27b5140 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,7 +12,7 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` policy is `never`. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. - **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. - **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. @@ -33,12 +33,12 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval policy is `never`, and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. +- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. - Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the `never` approval policy). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the absent approval service). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index ea8058878f..8b940f1973 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,7 +12,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 策略为 `never`。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 - **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 - **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 @@ -33,12 +33,12 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 策略为 `never`、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 +- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 - 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与 `never` approval 策略)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与缺席的 approval 服务)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 0cc7f47a1b..687c86ae0b 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 81932442fe9e2edac82bda88bbe28ce56fd8b6c1 -README.zh.md: b0af307b7655a8729a8face324a2750e2e480651 +README.md: 84e7f43aaa6cfead0ff28e30063e71062d358f0a +README.zh.md: 30c7c259f8893ebd34da6cab362dedac067ce099 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 81932442fe..84e7f43aaa 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), and the sandbox/fs-policy stack — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`, with the approval policy set to `never`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows has no sandbox** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack: `sandbox`/`sandbox-policy`/`fs-sandbox` are disabled, `dsh-fs-local` provides `ctx.fs`, the permission switcher leaves the roster, and the approval policy is `never`. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. +- **Windows has no sandbox and no approval** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack (`sandbox`/`sandbox-policy`/`fs-sandbox` disabled, `dsh-fs-local` provides `ctx.fs`), the permission switcher leaves the roster, and the approval service is disabled — nothing on Windows asks for approval, so the model is never told approval exists. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index b0af307b76..30c7c259f8 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)与 sandbox/fs 策略栈——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`,`approval` 策略设为 `never`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上没有沙箱**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`,权限切换器离开清单,`approval` 策略为 `never`。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 +- **Windows 上没有沙箱、没有 approval**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`——权限切换器离开清单,approval 服务也被禁用:Windows 上没有任何动作需要审批,模型也不会被告知审批存在。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 6adcab1715..dc18224dda 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -46,7 +46,9 @@ describe('dsh-base bundle', () => { .map(patch => patch.id) // The POSIX-only sandboxed stacks leave the Windows roster as one unit: // shell (bash-sandbox/tool-bash), the permission switcher it requires, - // and the fs/sandbox policy stack whose OS runners do not exist on win32. + // the fs/sandbox policy stack whose OS runners do not exist on win32, + // and the approval service — nothing on Windows asks for approval, so + // the model is never told approval exists or that asks auto-reject. expect(disables).toEqual( expect.arrayContaining([ 'bash-sandbox', @@ -56,6 +58,7 @@ describe('dsh-base bundle', () => { 'sandbox', 'sandbox-policy', 'fs-sandbox', + 'approval', ]), ) const inserted = parsed @@ -64,9 +67,7 @@ describe('dsh-base bundle', () => { expect(inserted).toEqual( expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), ) - // Full danger-full-access degradation: no approval prompts on Windows. - expect(parsed.find(patch => patch.id === 'approval')?.config).toEqual({ - policy: 'never', - }) + // Full danger-full-access degradation: no approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')?.config).toBeUndefined() }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 50dbe8d9e5..439861f922 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,12 +1,14 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 # hosts, between the bundle layers and the user layers. Windows cannot run # the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack -# for the PowerShell stack AND drops the sandbox entirely: no OS runner -# exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any policy -# would be theater — the unconfined shell could bypass fs-only path rules -# with one command. Windows therefore degrades to danger-full-access: +# for the PowerShell stack AND drops the whole permission surface: no OS +# runner exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any +# policy would be theater — the unconfined shell could bypass fs-only path +# rules with one command. Windows therefore degrades to danger-full-access: # unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher -# (dsh-permission requires a confining executor), approval policy `never`. +# (dsh-permission requires a confining executor), and no approval service — +# nothing in the roster asks for approval, and the model is never told +# approval exists or that requests are auto-rejected. # The launcher reads THIS file from the base bundle package (never through # dsh.bundle.patch — that field names the one universal layer). A Windows # host that prefers bash or confinement overrides these rows through its @@ -34,8 +36,7 @@ disabled: true - id: approval - config: - policy: never + disabled: true - insert: - id: pwsh-local diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 453109bcae..e99ec0f602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy From 653e22bc50c752aeda97b42dd05ad42ec98936ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 11:55:46 +0800 Subject: [PATCH 05/62] test(web): cover empty-draft queue flush with a keyless replay scenario Also pin the queue-item-not-found convergence arm through the hub and document the repeated-trigger contract on steerQueue. --- .../snapshots/steer-all/mid-steer.expected.md | 36 ++++++ .../snapshots/steer-all/replay.override.json | 47 ++++++++ .../snapshots/steer-all/settled.expected.md | 51 +++++++++ apps/web/tests/steering.e2e.ts | 107 ++++++++++++++++++ .../ui-conversation/src/client/input/hub.ts | 5 +- .../tests/service-orchestration.spec.ts | 13 +++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/snapshots/steer-all/mid-steer.expected.md create mode 100644 apps/web/tests/snapshots/steer-all/replay.override.json create mode 100644 apps/web/tests/snapshots/steer-all/settled.expected.md diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md new file mode 100644 index 0000000000..3546fe018f --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -0,0 +1,36 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think": + - img + - img + - text: Think +- status: Deep diving... +- text: "Interjection Interjection: include the word BANANA in your final reply." +- button "Copy": + - img +- text: "Interjection Interjection: include the word ORANGE in your final reply." +- button "Copy": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/steer-all/replay.override.json b/apps/web/tests/snapshots/steer-all/replay.override.json new file mode 100644 index 0000000000..6a1c133faf --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/replay.override.json @@ -0,0 +1,47 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { + "type": "tool-call-delta", + "index": 1, + "id": "call_00_steer_all", + "name": "ask_user_question", + "argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." + } + }, + { + "type": "block-end", + "index": 1, + "block": { + "type": "tool-call", + "id": "call_00_steer_all", + "name": "ask_user_question", + "arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + } + }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md new file mode 100644 index 0000000000..aad8000181 --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -0,0 +1,51 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 6c96f9b6aa..182f892d54 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' +// Empty-draft flush scenario: an override-only fixture. The whole-script +// replacement answers both model calls of a FRESH session (no recorded +// session.jsonl exists — call 0 keeps the turn open with a question-tool +// call, call 1 is the reply after both steerings drain). +const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url)) +const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') +const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') +const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') +const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' +const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' + /** Concatenated assistant text deltas — the model-visible reply body. */ function assistantText(events: SessionEvent[]): string { return events @@ -278,3 +290,98 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) }) + +describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + // The scenario boots a fresh session against the override-only fixture; + // the replay.override.json sidecar replaces the derived script, so the + // (deliberately absent) session.jsonl is never read. + scaffold = await launchWebScaffold({ + replayFixture: STEER_ALL_FIXTURE, + replayOverride: STEER_ALL_OVERRIDE, + paceMs: REPLAY_PACE_MS, + }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + + // Call 0 streams a question-tool call; the fills must land inside the + // first replay window, before the question composer replaces the textarea. + await input.fill(PROMPT) + await input.press('Enter') + await input.fill(STEER_ONE) + await input.press('Enter') + await input.fill(STEER_TWO) + await input.press('Enter') + const dock = page.locator('[data-queue-dock]') + // Both messages queued: the two-row dock shows a collapsed count header, + // and Playwright text matching skips the hidden rows — expand the list, + // then assert each row's content. + await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 }) + await dock.getByRole('button').click() + await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 }) + await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + + // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock + // empties, and the pending steering renders at the conversation tail. + await input.press('Meta+Enter') + await expect.poll( + () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(), + { timeout: 10_000 }, + ).toBe(2) + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) + + // Answer the question; the step closes, the loop drains both steerings + // into one next-step request, and the final reply obeys both markers. + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const first = claimedMessages(sessionEvents, STEER_ONE) + const second = claimedMessages(sessionEvents, STEER_TWO) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(assistantText(sessionEvents)).toContain('BANANA') + expect(assistantText(sessionEvents)).toContain('ORANGE') + await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(STEER_ALL_DIR, [ + 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + ]) + }) +}) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index e9e58035d2..e0dbb715a1 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -152,7 +152,10 @@ export class InputHub implements InputService { * order — the same strict-steer operation as the queue dock's per-row * button. A turn closing mid-way (`steer-unavailable`) or a row already * claimed by the agent (`queue-item-not-found`) converges silently, while a - * genuine failure surfaces as one composer notice. + * genuine failure surfaces as one composer notice. Repeated triggers + * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * convergence: the snapshot may still list a row the host already steered, + * and the duplicate strict steer is a silent no-op. * @param session - the addressed host session. * @param shell - the resident shell (notice outlet). */ diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 126cb65a47..c8a7be4dc9 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -125,12 +125,25 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { await b.runtime.sessions.updateSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) + // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) expect(b.shell.notices.getSnapshot()).toBeNull() + + // A row the host already claimed (e.g. a repeated empty-draft chord): + // the duplicate strict steer is a silent no-op. + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-3')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) + expect(b.shell.notices.getSnapshot()).toBeNull() await b.runtime.dispose() }) From 9597ff9bf7a470d368c0b9848a309f01ced19cee Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 06/62] feat(web): advertise whole-queue steering shortcut --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 ++-- .../2026-08-06-web-queue-steer-all-gesture.md | 5 +++- ...26-08-06-web-queue-steer-all-gesture.zh.md | 5 +++- .../queue-actions/collapsed.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../queue-actions/layout.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/InputBar.tsx | 9 ++++--- .../ui-conversation/tests/input-bar.spec.tsx | 24 +++++++++++++++++++ 13 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index 5b51304fe6..0e3cc06a83 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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-08-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc -2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 +2026-08-06-web-queue-steer-all-gesture.md: c4452c16fe01f876aff72715a7d1056a09ae1e1f +2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index e65089fca7..c4452c16fe 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -14,9 +14,11 @@ Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inb The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence. + ## Consequences -One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative. ## Related @@ -28,3 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. +- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index abccf60a9d..27e147304e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -14,9 +14,11 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先。 + ## Consequences -一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控;hub 在执行时会重新读取快照,因此客户端门控仍只是建议性的,host 仍是权威。 ## Related @@ -28,3 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 +- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdde5d8790..3dc7f577af 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -17,7 +17,7 @@ - paragraph: partial - status: Deep diving... - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8bfd2f964d..935da27c5c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -33,7 +33,7 @@ - tooltip "Save queued message" - button "Cancel editing": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 7370a15264..1f9c239c60 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -29,7 +29,7 @@ - button "Clear goal": - img - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48b714a88c..65c455975d 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -26,7 +26,7 @@ - img - button "Steer queued message": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 974f8a65b0..d447dbba84 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 31ae990d2d97757a4d7ea06b323e2131f764b5f7 -README.zh.md: 0f21da2f48dbfd6673fb02d6719390bec872afeb +README.md: c3b5470501cbbe08054c177436a211bc02bb077b +README.zh.md: 9bb19a6f04a7ac4aebc8af0bff0014bbdfc0b755 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31ae990d2d..c3b5470501 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0f21da2f48..9bb19a6f04 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..9fd3b21b74 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', + 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -162,6 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', + 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7a85ef7e08..5303c7b7d9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,6 +89,8 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + const canSteerQueue = !locked && !machineBusy && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -253,8 +255,7 @@ export function InputBar({ // still-pending queued message into the running turn (the dock's per-row // steer button applied to the whole queue). Steering needs the same // window as the per-row button: a running ordinary session. - if (accelerated && empty && running && subagent === null - && input.queue.some(row => row.placement === 'queued')) { + if (accelerated && canSteerQueue) { keyboard.steerQueue() return } @@ -485,7 +486,9 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + : canSteerQueue + ? t('input.steerQueueShortcut') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 67a9b48e90..f8127fa892 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -169,6 +169,30 @@ function bench(over?: BenchOptions) { } describe('Enter semantics', () => { + it('advertises the empty-draft whole-queue steering gesture when it is available', () => { + const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() }) + expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { + expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + subagent: { + address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }, + }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + placeholder: '上层指定提示', + }).textarea.placeholder).toBe('上层指定提示') + }) + it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) From 1e759608f6c7717eab14ffccd39f37be8c8324d4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 13:48:33 +0800 Subject: [PATCH 07/62] test(web): refresh steer-all goldens against merged master UI --- .../tests/snapshots/steer-all/mid-steer.expected.md | 3 --- apps/web/tests/snapshots/steer-all/settled.expected.md | 10 +--------- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 3546fe018f..65f22f2140 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index aad8000181..a61f57572e 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img @@ -25,14 +22,9 @@ - text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - paragraph: "Got it: BANANA and ORANGE." - button "Copy": - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..0734355828 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 8d5034a84edd56b7c9b411f6e281d1c3b7733a82 +README.zh.md: 309bf54c2b1759c33f21ada33184e2a118b2f00f From 4ede2798fead9f69387a799ec372be1eade4beaf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:20:14 +0800 Subject: [PATCH 08/62] =?UTF-8?q?fix(web):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20placeholder=20key=20family,=20menu-open=20gate,=20plan=20pre?= =?UTF-8?q?cedence,=20note=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +-- .../2026-08-06-web-queue-steer-all-gesture.md | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 4 +-- .../src/client/skeleton/InputBar.tsx | 7 +++-- .../ui-conversation/tests/input-bar.spec.tsx | 27 +++++++++++++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index 0e3cc06a83..1935c6de2e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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-08-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: c4452c16fe01f876aff72715a7d1056a09ae1e1f -2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df +2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 +2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index c4452c16fe..e546f68647 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -14,7 +14,7 @@ Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inb The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. -The same computed availability gate drives discovery: while the draft is empty, the input is unlocked, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence. +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window). ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index 27e147304e..fb36852f66 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -14,7 +14,7 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 -同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定且不处于瞬态机器锁(adjudicating/submitting)、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder(该窗口内手势确实可用)。 ## Consequences diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9fd3b21b74..20aab2cfda 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,7 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter 插话发送全部排队消息', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -163,7 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter steers all queued messages', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5303c7b7d9..e326c8e523 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,7 +89,7 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - const canSteerQueue = !locked && !machineBusy && empty && running && subagent === null + const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the @@ -486,8 +486,11 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. : canSteerQueue - ? t('input.steerQueueShortcut') + ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8127fa892..4ae6626b14 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -191,6 +191,33 @@ describe('Enter semantics', () => { queue: [row('q-1')], placeholder: '上层指定提示', }).textarea.placeholder).toBe('上层指定提示') + // The command menu owns Enter while open: neither the hint nor the + // gesture may claim the chord. + expect(bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + }).textarea.placeholder).toBe('给智能体发消息') + // The steer hint intentionally outranks the plan placeholder: while it + // shows, the whole-queue gesture is genuinely available in plan mode. + expect(bench({ + running: true, + queue: [row('q-1')], + plan: { active: true, pending: false }, + }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('an open command menu withholds the whole-queue steering gesture', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + steerQueue, + }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(steerQueue).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() }) it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { From 94f61d29fa7da8021735688aafb04cb528648133 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 19:21:43 +0800 Subject: [PATCH 09/62] fix(scripts): register dsh-base windows.cordis.patch.yml in workspace constraints --- scripts/check-workspace-constraints.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..f0cea80064 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -102,8 +102,9 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib; + // dsh-base also ships the win32 shell platform layer the launcher reads. + '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], From 1ee773317cccb88ddd35aec6ab49678ae69552c4 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 19:21:49 +0800 Subject: [PATCH 10/62] docs(bundle): complete the Windows pwsh default contract per review - apps/cli/reference/README: state the win32 permission/sandbox/approval degradation so the workspace-write promise no longer misleads Windows users - bundle README + agent note: give the complete bash-restore recipe (disable pwsh-local/tool-pwsh and re-enable bash-sandbox/tool-bash), since both executors register the same bash service and an incomplete recipe fails loud at load - windows.cordis.patch.yml: header comment notes the recipe and that the ui-permission row belongs to dsh-web-app (base-only profiles get a harmless no-match warning) - profile-boot.ts: rewrap composeProfile JSDoc - re-record i18n hashes for the touched bilingual pairs --- .../feature/2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../feature/2026-08-01-windows-pwsh-default.md | 2 +- .../feature/2026-08-01-windows-pwsh-default.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 10 +++++----- packages/bundle/base/README.i18n.yaml | 4 ++-- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/windows.cordis.patch.yml | 8 ++++++++ 11 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 7ab92d8199..48f7c38aa5 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 75a27b5140abacf77b5f20028752a4903a50eaec -2026-08-01-windows-pwsh-default.zh.md: 8b940f1973835f8143bde05b37eba6f00e27f8f4 +2026-08-01-windows-pwsh-default.md: 4cd17497c0185ba61fb4994a9167d114640b4866 +2026-08-01-windows-pwsh-default.zh.md: 201e5dd2c7c0b791bf6474261846bc7f4061cc5b diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 75a27b5140..4cd17497c0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -35,7 +35,7 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). - Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 8b940f1973..201e5dd2c7 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -35,7 +35,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 - Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..29cdfac304 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 07b162517b90215a6552810124f15c435144f7d4 +README.zh.md: 80030707474e59e76583ed7edc5ddce56334cc03 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..07b162517b 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -51,7 +51,7 @@ Process shutdown gives the plugin tree up to five seconds to dispose. The first All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the Windows platform layer removes the permission, sandbox, and approval rows entirely: the pwsh shell and the fs tools run unconfined with no workspace boundary (Windows has no OS sandbox runner — landlock/bwrap/seatbelt are POSIX-only — so the shipped posture is honest danger-full-access rather than a boundary the shell could bypass), and `DSH_PERMISSION_MODE` and stored permission settings have no effect there. `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..8003070747 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -51,7 +51,7 @@ dsh web --dump-config 所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 +新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,Windows 平台层会整体移除 permission、sandbox 与 approval 行:pwsh shell 与 fs 工具不受限运行,不存在 workspace 边界(Windows 上没有 OS 级 sandbox runner——landlock/bwrap/seatbelt 均为 POSIX 专属——因此交付姿态是诚实的 danger-full-access,而不是一个 shell 可以绕过的边界),`DSH_PERMISSION_MODE` 与存储的权限设置在彼处也不生效。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 7115f1208a..6dab902845 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -132,11 +132,11 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in * `dsh.profile.bundles` order, the win32 shell platform layer (when the host - * is Windows), the - * profile's user layer, the home-level user layer (`$DSH_HOME/cordis.patch.yml` - * — machine-local preferences that apply to every profile, so it outranks the - * per-profile layer), `--patch` overlays, then flag patches derived from the - * composed rows, then the telemetry switch. + * is Windows), the profile's user layer, the home-level user layer + * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to + * every profile, so it outranks the per-profile layer), `--patch` overlays, + * then flag patches derived from the composed rows, then the telemetry + * switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 687c86ae0b..cc58dd3f8f 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 84e7f43aaa6cfead0ff28e30063e71062d358f0a -README.zh.md: 30c7c259f8893ebd34da6cab362dedac067ce099 +README.md: 9e2a6305fff592eb63f927dfcc4f32437edeb1cc +README.zh.md: 6159c23047bba4ced3345125901cc0c3ff7cd960 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 84e7f43aaa..9e2a6305ff 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack restores it through its profile or home `cordis.patch.yml` (disable `pwsh-local`/`tool-pwsh` and re-enable `bash-sandbox`/`tool-bash` — both executors register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 30c7c259f8..6159c23047 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 恢复 bash 栈(禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 439861f922..1abd4ba568 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -13,6 +13,14 @@ # dsh.bundle.patch — that field names the one universal layer). A Windows # host that prefers bash or confinement overrides these rows through its # profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-local and tool-pwsh +# AND re-enable bash-sandbox and tool-bash (plus permission/ui-permission only +# if the switcher is wanted) — both executors register the same 'bash' +# service, so re-enabling the bash rows while pwsh-local stays inserted fails +# loud at load on a duplicate registration. +# The ui-permission disable targets a row owned by dsh-web-app, not dsh-base: +# a base-only profile (e.g. the `dsh plugin --profile` default template) has +# no such row, and the no-match logs a harmless warning on every load. - id: bash-sandbox disabled: true From 431783426e62fa5897a503546ba39f705260e173 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 20:50:36 +0800 Subject: [PATCH 11/62] test(cli): compose the real Windows shell roster through the shipped bundle layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver suite previously exercised only fixture patch lists, leaving the real shipped windows.cordis.patch.yml and the bundle→windows→user composition ordering untested on Linux CI. The new cases load a temp profile whose bundle layers resolve from the real dsh-base/dsh-web-app packages (app installation anchor), apply the platform layer through the boot's own composeEntries algorithm with the platform injected, and assert the win32 danger-full-access roster (eight disables, three inserts, no warnings on the web profile). A second case pins POSIX unchanged and the base-only-profile ui-permission no-match warning as warned-but-harmless, matching the patch header's documented contract. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 2 +- .../2026-08-01-windows-pwsh-default.zh.md | 2 +- apps/cli/tests/windows-shell.spec.ts | 60 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 48f7c38aa5..af9f64208f 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 4cd17497c0185ba61fb4994a9167d114640b4866 -2026-08-01-windows-pwsh-default.zh.md: 201e5dd2c7c0b791bf6474261846bc7f4061cc5b +2026-08-01-windows-pwsh-default.md: ea45040a0eb6f1325ed8f3a02a477d5dfd614696 +2026-08-01-windows-pwsh-default.zh.md: 41324afccdd775cac1629ed69217cff4237be091 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 4cd17497c0..ea45040a0e 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -39,6 +39,6 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the absent approval service). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 201e5dd2c7..41324afccd 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -39,6 +39,6 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与缺席的 approval 服务)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index f0cfddbaae..80ba40cc34 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' import { BASE_BUNDLE, resolveWindowsShellLayer, @@ -62,3 +64,61 @@ describe('resolveWindowsShellLayer', () => { .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) }) }) + +describe('the shipped Windows composition (real bundle layers)', () => { + let home: string + afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) }) + // The app installation anchor, mirroring profile-boot.ts: the bundle layers + // resolve from the REAL dsh-base/dsh-web-app packages through it, so this + // suite composes the shipped patch files, not test fixtures. + const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) + + it('composes the win32 danger-full-access roster through the real patch layers', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + const warnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh') + expect(win32).toBeDefined() + const rows = composeEntries( + [...profile.layers.map(layer => layer.patches), win32!.patches], + message => warnings.push(message), + ) + const byId = new Map(rows.map(row => [row.id, row])) + for (const id of ['bash-sandbox', 'tool-bash', 'permission', 'ui-permission', + 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) + } + for (const id of ['pwsh-local', 'tool-pwsh', 'fs-local']) { + expect(byId.has(id), `inserted row ${id}`).toBe(true) + } + // The web-app layer provides ui-permission, so the full web profile + // composes without any no-match warning. + expect(warnings).toEqual([]) + }) + + it('leaves POSIX untouched and base-only profiles warned but harmless', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + // POSIX: no platform layer, the bash stack stays enabled. + const posixRows = composeEntries(profile.layers.map(layer => layer.patches)) + const posixById = new Map(posixRows.map(row => [row.id, row])) + expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) + expect(posixById.has('pwsh-local')).toBe(false) + + // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): + // ui-permission has no row to patch, so the shipped layer warns once per + // composition — never fails — exactly as its header comment documents. + initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) + const baseOnly = loadProfile('dsh', 'base-only', anchor, home) + const baseWarnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh') + expect(win32).toBeDefined() + composeEntries( + [...baseOnly.layers.map(layer => layer.patches), win32!.patches], + message => baseWarnings.push(message), + ) + expect(baseWarnings.some(message => message.includes('ui-permission'))).toBe(true) + }) +}) From f64ba40f43961fc65b7e1724132708a0b6d163cf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 01:24:33 +0800 Subject: [PATCH 12/62] feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner) Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed. - @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions). - @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules). - @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec. - bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled. Co-authored-by: Huo Yaoyuan --- packages/bash/pwsh-local/src/index.ts | 48 ++- packages/bash/pwsh-sandbox/README.i18n.yaml | 6 + packages/bash/pwsh-sandbox/README.md | 24 ++ packages/bash/pwsh-sandbox/README.zh.md | 24 ++ packages/bash/pwsh-sandbox/package.json | 46 +++ packages/bash/pwsh-sandbox/src/helpers.ts | 120 +++++++ packages/bash/pwsh-sandbox/src/index.ts | 184 ++++++++++ packages/bash/pwsh-sandbox/src/invariant.ts | 30 ++ packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 115 +++++++ .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 320 ++++++++++++++++++ packages/bash/pwsh-sandbox/tsconfig.json | 39 +++ packages/bundle/base/tests/base.spec.ts | 36 +- packages/bundle/base/windows.cordis.patch.yml | 59 +--- packages/sandbox/sandbox-local/package.json | 3 +- packages/sandbox/sandbox-local/src/index.ts | 85 ++++- .../sandbox/sandbox-local/tests/local.spec.ts | 11 +- .../sandbox-windows-acl/README.i18n.yaml | 6 + .../sandbox/sandbox-windows-acl/README.md | 64 ++++ .../sandbox/sandbox-windows-acl/README.zh.md | 64 ++++ .../sandbox/sandbox-windows-acl/package.json | 44 +++ .../sandbox/sandbox-windows-acl/src/acl.ts | 113 +++++++ .../sandbox/sandbox-windows-acl/src/errors.ts | 21 ++ .../sandbox/sandbox-windows-acl/src/ffi.ts | 307 +++++++++++++++++ .../sandbox/sandbox-windows-acl/src/index.ts | 280 +++++++++++++++ .../sandbox-windows-acl/src/invariant.ts | 31 ++ .../sandbox/sandbox-windows-acl/src/runner.ts | 135 ++++++++ .../sandbox/sandbox-windows-acl/src/spawn.ts | 296 ++++++++++++++++ .../sandbox/sandbox-windows-acl/src/token.ts | 138 ++++++++ .../sandbox-windows-acl/src/win32-abi.ts | 136 ++++++++ .../sandbox-windows-acl/tests/probe.spec.ts | 97 ++++++ .../tests/provider-chain.spec.ts | 58 ++++ .../sandbox-windows-acl/tests/runner.spec.ts | 107 ++++++ .../sandbox/sandbox-windows-acl/tsconfig.json | 22 ++ .../sandbox-windows-acl/tsdown.config.ts | 16 + .../sandbox-windows-acl/verify/abi-probe.cpp | 177 ++++++++++ pnpm-lock.yaml | 49 +++ tsconfig.host.json | 2 + vitest.config.ts | 11 + 38 files changed, 3230 insertions(+), 94 deletions(-) create mode 100644 packages/bash/pwsh-sandbox/README.i18n.yaml create mode 100644 packages/bash/pwsh-sandbox/README.md create mode 100644 packages/bash/pwsh-sandbox/README.zh.md create mode 100644 packages/bash/pwsh-sandbox/package.json create mode 100644 packages/bash/pwsh-sandbox/src/helpers.ts create mode 100644 packages/bash/pwsh-sandbox/src/index.ts create mode 100644 packages/bash/pwsh-sandbox/src/invariant.ts create mode 100644 packages/bash/pwsh-sandbox/tests/acl.e2e.ts create mode 100644 packages/bash/pwsh-sandbox/tests/sandbox.spec.ts create mode 100644 packages/bash/pwsh-sandbox/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/README.i18n.yaml create mode 100644 packages/sandbox/sandbox-windows-acl/README.md create mode 100644 packages/sandbox/sandbox-windows-acl/README.zh.md create mode 100644 packages/sandbox/sandbox-windows-acl/package.json create mode 100644 packages/sandbox/sandbox-windows-acl/src/acl.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/errors.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/ffi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/index.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/invariant.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/runner.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/spawn.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/token.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/win32-abi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/tsdown.config.ts create mode 100644 packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 20ecffd969..ceef4725fb 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + /** + * The pwsh invocation argv for one resolved spec — the argv-level seam a + * confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of + * `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see + * `@deepseek-ai/dsh-pwsh-sandbox`). + */ + protected argv(spec: BashExecSpec): string[] { + return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`] + } + + /** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */ + private spawnSpec( + spec: BashExecSpec, + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + argv: readonly string[], + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + argv: [...argv], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise { + return this.runArgv(spec, this.argv(spec)) + } + + /** Foreground run of an exact argv (the confining subclass re-wraps it). */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv)) const outcome = await handle.done const collected = PwshLocalExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, this.argv(spec)) + } + + /** Background start of an exact argv (the confining subclass re-wraps it). */ + protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, collected.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. * The base implementation is intentionally empty. Mirrored from - * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is - * the declared seam for a future pwsh-confining subclass and has no consumer - * in this package yet. + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the + * pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnFailed - whether the spawn rejected before any process existed. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } /* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..7110763e72 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -0,0 +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/bash/pwsh-sandbox/README.md +README.md: 5eaa513b7802fe1b4411a5c0bafabafd232d4da7 +README.zh.md: 4924feed26cb7bc50d4863a063d9a4b0bf26d954 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md new file mode 100644 index 0000000000..5eaa513b78 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +English | [中文](README.zh.md) + +Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt. + +The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy). + +## Behavior + +- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`. +- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`. + +## Model Experience + +### Confinement works, denial surfaces as command failure + +The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +## Known Limitations and Deferred Work + +- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`), mirroring Landlock's `/tmp` grant — a per-run private temp would need an env-block rewrite in the runner and is deferred. +- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md new file mode 100644 index 0000000000..4924feed26 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +[English](README.md) | 中文 + +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 + +执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 + +## 行为 + +- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。 +- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。 + +## 模型体验 + +### 隔离生效,拒绝以命令失败呈现 + +模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +## 已知限制与后续工作 + +- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。 +- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json new file mode 100644 index 0000000000..47f51b2ac1 --- /dev/null +++ b/packages/bash/pwsh-sandbox/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-sandbox", + "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pwsh-local": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-sandbox/src/helpers.ts b/packages/bash/pwsh-sandbox/src/helpers.ts new file mode 100644 index 0000000000..44524a3d87 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/helpers.ts @@ -0,0 +1,120 @@ +/** + * Internal sandbox-result classification helpers — deliberate call-for-call + * mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of + * the bash consumer shares the identical classification dialect). + * + * @module @deepseek-ai/dsh-pwsh-sandbox/helpers + */ + +/* jscpd:ignore-start */ +import { accessSync, constants, statSync } from 'node:fs' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} + +/** + * Classify a failed run against the selected backend's denial dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive denial substrings from the active wrap. + * @returns whether the failed run matches that denial dialect. + */ +export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. + */ +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined +} + +/** + * Match a non-zero exit against case-insensitive stderr signatures. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text. + * @param signatures - substrings identifying the selected backend's dialect. + * @returns whether this is a non-zero exit whose stderr matches a signature. + */ +export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { + if (exitCode === null || exitCode === 0) return false + const lowered = stderr.toLowerCase() + return signatures.some(signature => lowered.includes(signature.toLowerCase())) +} +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts new file mode 100644 index 0000000000..c88719f889 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -0,0 +1,184 @@ +/** + * Sandbox-consuming PowerShell executor — the pwsh twin of + * `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through + * `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner + * chain), inherits local process mechanics, and reports the selected mode, + * enforcement, and denial facts. Positive runner-launch evidence means the + * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool owns approval and passes a complete + * per-call policy. + * @module @deepseek-ai/dsh-pwsh-sandbox + */ + +import { Context } from 'cordis' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedArgv, + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' + +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig + +/** + * Registers as `ctx.bash` in place of the local pwsh executor and requires a + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is + * unchanged. Tool calls pass the calling session's resolved policy; direct + * calls fall back to deployment policy. `result.sandbox` reports the mode and + * enforcement actually used. + */ +export class SandboxPwshExecutor extends PwshLocalExecutor { + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] + + // No own Config: the sandbox default (mode + workspaceRoot) moved to + // ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config + // verbatim (the config catalog walks the inherited static). + + private readonly mode: SandboxMode + /** + * Per-process confinement facts retained until settlement. Providers may + * vary enforcement and diagnostic dialect between overlapping calls, so a + * shared latest-wrap value would classify a process against the wrong facts. + * Unconfined processes have no entry. + */ + private readonly processFacts = new Map() + + constructor(ctx: Context, config: Config) { + super(ctx, config) + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. + this.mode = ctx.sandboxPolicy.defaultMode + } + + /** The configured default mode — the capability fact the tool layer reads. */ + override get sandboxMode(): SandboxMode { + return this.mode + } + + /** + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. + */ + override resolve(request: BashExecRequest): BashExecSpec { + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } + } + + override async run(spec: BashExecSpec): Promise { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') { + const result = await super.run(spec) + return { ...result, sandbox: { mode, denied: false } } + } + const confined = this.confine(spec, { ...policy, mode }) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) + } + return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } + } + + override start(spec: BashExecSpec): BashProcess { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') return super.start(spec) + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. + const confined = this.confine(spec, { ...policy, mode }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) + return proc + } + + /** + * Stamp per-process sandbox facts before `done` settles. Full-access + * processes have no facts; signal deaths are not denials. + */ + protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + const facts = this.processFacts.get(proc) + if (facts !== undefined) { + this.processFacts.delete(proc) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + proc.sandbox = { + mode: facts.mode, + denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), + enforcement: facts.enforcement, + ...(runnerFailed ? { runnerFailed } : {}), + } + } + super.onProcessDone(proc, stderr, spawnFailed, spawnError) + } + + /** + * Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param spec - resolved execution spec whose pwsh argv is confined. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. + */ + private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(this.argv(spec), policy) + } +} + +export default SandboxPwshExecutor diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts new file mode 100644 index 0000000000..6afda519ff --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`. + * @module @deepseek-ai/dsh-pwsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-sandbox-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond contracts enforced at its owning seams. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts new file mode 100644 index 0000000000..a5909f0544 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -0,0 +1,115 @@ +/** + * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the + * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with + * REAL pwsh spawns confined through the runner — the debug-instance + * verification of both modes: read-only denies every write (not even NUL), + * workspace-write allows the workspace and temp while denying escape writes, + * and denial/classification facts ride the settled result. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let executor!: SandboxPwshExecutor + + beforeAll(async () => { + // The escape probe must live OUTSIDE every legitimately granted tree: the + // provider's workspace-write grants the workspace plus the REAL temp dir + // (the 'backend-defined temp area', same as Landlock granting /tmp), so a + // scratch dir under temp would inherit the grant and the probe would be a + // false pass. A mkdtemp under the profile is removed by afterAll. + scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxPwshExecutor, {}) + executor = ctx.bash as SandboxPwshExecutor + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: DENIED') + expect(result.stdout.text).toContain('TEMP-WRITE: DENIED') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) + // A self-caught denial keeps the command exit 0: no denial fact. + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + + // A raw failing write must classify as a denial of the ACL dialect. + const denied = await executor.run(executor.resolve({ + command: `Set-Content -Path '${escapeFile}' -Value x`, + sandboxPolicy: policy, + })) + expect(denied.exitCode).not.toBe(0) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 60_000) + + it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: OK') + expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(escapeFile)).toBe(false) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + }, 60_000) +}) diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts new file mode 100644 index 0000000000..1c8e5e1bd0 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -0,0 +1,320 @@ +/** + * Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service + * makes wrapping, policy hand-off, fail-closed propagation, and fact stamping + * deterministic; real-provider integration lives in `tests/acl.e2e.ts`. + * Requires pwsh for the integration block (skips without it — same gate as + * pwsh-local's suites); the helpers block is pure and always runs. + */ + +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' +import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) + +/** One recorded provider call: the argv handed over and the policy it rode with. */ +interface ConfineCall { + argv: string[] + policy: SandboxPolicy +} + +/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ +const passthrough = (argv: readonly string[]): ConfinedArgv => + ({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] }) + +/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */ +function throwingSubprocessService(error: unknown): new (ctx: Context) => Service { + return class extends Service { + constructor(ctx: Context) { + super(ctx, 'subprocess') + } + + spawn(): never { + throw error + } + } +} + +async function setup( + behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough, + subprocess: new (ctx: Context) => Service = LocalSubprocessService, +): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> { + const calls: ConfineCall[] = [] + class FakeSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + calls.push({ argv: [...argv], policy }) + return behavior(argv, policy) + } + } + const ctx = new Context() + await ctx.plugin(FakeSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) + await ctx.plugin(subprocess) + if (ctx.subprocess instanceof LocalSubprocessService) { + ctx.subprocess.internals = { spillDir } + } + await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 }) + return { executor: ctx.bash as SandboxPwshExecutor, calls } +} + +describe('helpers (pure)', () => { + const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-')) + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe('isRunnerSpawnFailure', () => { + const absolute = process.execPath + const bare = 'node' + const relative = './sandbox-runner' + + it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => { + for (const runnerProgram of [absolute, bare, relative]) { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true) + } + }) + + it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false) + expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false) + // An existing FILE (not a directory) workdir is unusable without throwing. + const fileWorkdir = join(workdir, 'a-file') + writeFileSync(fileWorkdir, 'x') + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false) + }) + }) + + describe('classifyRunnerFailure', () => { + const rules: readonly RunnerFailureRule[] = [{ + allowedExitCodes: [127], + fatalSignatures: ['fake-runner: '], + informationalLines: ['fake-runner: partial enforcement'], + }] + + it('matches a fatal signature on a gated exit code, skipping informational lines', () => { + expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules)) + .toEqual({ detail: 'fake-runner: profile refused' }) + }) + + it('rejects zero/null exits, gate mismatches, and empty signatures', () => { + expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() + }) + }) + + describe('matchesSignature', () => { + it('matches non-zero exits case-insensitively, never zero or signal exits', () => { + expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true) + expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true) + expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false) + expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false) + expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false) + }) + }) +}) + +describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { + // Denial device for the POSIX classification cases: a mode-0555 directory + // INSIDE a temp scratch tree (the same device as bash-sandbox's suites) — + // unit tests never attempt writes outside the system temp directory. On + // win32 there is no POSIX mode denial; the real-sandbox denial coverage + // lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths. + const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-')) + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555) + const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')` + + afterAll(() => { + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755) + rmSync(readOnlyDir, { recursive: true, force: true }) + rmSync(spillDir, { recursive: true, force: true }) + }) + + const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' } + + it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(1) + const call = calls[0] + expect(call?.policy).toEqual(RO) + // The confined argv is the pwsh invocation, ready for a runner prefix. + expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u) + expect(call?.argv).toContain('-NonInteractive') + expect(call?.argv.at(-1)).toContain('echo wrapped') + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => { + const { executor, calls } = await setup() + expect(executor.sandboxMode).toBe('workspace-write') + const result = await executor.run(executor.resolve({ command: 'echo fallback' })) + expect(result.exitCode).toBe(0) + expect(calls[0]?.policy.mode).toBe('workspace-write') + }, 30_000) + + it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(0) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + }, 30_000) + + it('an aborted caller signal outranks runner-spawn attribution', async () => { + const controller = new AbortController() + controller.abort('caller-cancel') + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [], + })) + await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal }))) + .rejects.toThrow('caller-cancel') + }, 30_000) + + // POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the + // real-sandbox denial classification is covered by tests/acl.e2e.ts + // (the ACL runner denies scratch paths — unit tests never leave temp). + it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => { + const { executor } = await setup() + const result = await executor.run(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => { + const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow('sync-emfile') + }, 30_000) + + it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => { + const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow('sync-emfile-start') + }, 30_000) + + it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => { + const { executor } = await setup(() => ({ + argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('background confined runs stamp clean facts at settlement', async () => { + const { executor } = await setup() + const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO })) + await clean.done + expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + // POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial + // coverage lives in tests/acl.e2e.ts. + it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => { + const { executor } = await setup() + const denied = executor.start(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + await denied.done + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('background spawn rejections settle as runnerFailed facts', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO })) + await proc.done + expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true }) + // The failure note surfaces through the read path. + const read = proc.readOutput() + expect(read.delta).toContain('spawn failed') + }, 30_000) + + it('danger-full-access background runs bypass confine and carry no facts', async () => { + const { executor, calls } = await setup() + const proc = executor.start(executor.resolve({ + command: 'echo full-bg', + sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' }, + })) + await proc.done + expect(calls).toHaveLength(0) + expect(proc.sandbox).toBeUndefined() + }, 30_000) +}) diff --git a/packages/bash/pwsh-sandbox/tsconfig.json b/packages/bash/pwsh-sandbox/tsconfig.json new file mode 100644 index 0000000000..55eac06470 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/pwsh-local" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index dc18224dda..ab2dc874ce 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -30,7 +30,7 @@ describe('dsh-base bundle', () => { expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) - it('ships the Windows platform layer as the documented danger-full-access roster', () => { + it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const parsed = yaml.load( readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), @@ -44,30 +44,20 @@ describe('dsh-base bundle', () => { const disables = parsed .filter(patch => patch.disabled === true) .map(patch => patch.id) - // The POSIX-only sandboxed stacks leave the Windows roster as one unit: - // shell (bash-sandbox/tool-bash), the permission switcher it requires, - // the fs/sandbox policy stack whose OS runners do not exist on win32, - // and the approval service — nothing on Windows asks for approval, so - // the model is never told approval exists or that asks auto-reject. - expect(disables).toEqual( - expect.arrayContaining([ - 'bash-sandbox', - 'tool-bash', - 'permission', - 'ui-permission', - 'sandbox', - 'sandbox-policy', - 'fs-sandbox', - 'approval', - ]), - ) + // Only the POSIX bash stack is disabled: the Windows roster confines the + // pwsh executor through the ACL runner chain, so the sandbox/policy rows, + // the permission switcher, fs-sandbox, and the approval service all stay + // enabled exactly as on POSIX — only the shell is swapped. + expect(disables).toEqual(['bash-sandbox', 'tool-bash']) const inserted = parsed .flatMap(patch => patch.insert ?? []) .map(row => row.id) - expect(inserted).toEqual( - expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), - ) - // Full danger-full-access degradation: no approval surface at all. - expect(parsed.find(patch => patch.id === 'approval')?.config).toBeUndefined() + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh', 'fs-local']) + // The patch no longer touches the permission/approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 1abd4ba568..66ed54112f 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,26 +1,19 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers. Windows cannot run -# the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack -# for the PowerShell stack AND drops the whole permission surface: no OS -# runner exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any -# policy would be theater — the unconfined shell could bypass fs-only path -# rules with one command. Windows therefore degrades to danger-full-access: -# unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher -# (dsh-permission requires a confining executor), and no approval service — -# nothing in the roster asks for approval, and the model is never told -# approval exists or that requests are auto-rejected. -# The launcher reads THIS file from the base bundle package (never through -# dsh.bundle.patch — that field names the one universal layer). A Windows -# host that prefers bash or confinement overrides these rows through its -# profile or home cordis.patch.yml. -# The bash-restore recipe must be complete: disable pwsh-local and tool-pwsh -# AND re-enable bash-sandbox and tool-bash (plus permission/ui-permission only -# if the switcher is wanted) — both executors register the same 'bash' -# service, so re-enabling the bash rows while pwsh-local stays inserted fails -# loud at load on a duplicate registration. -# The ui-permission disable targets a row owned by dsh-web-app, not dsh-base: -# a base-only profile (e.g. the `dsh plugin --profile` default template) has -# no such row, and the no-match logs a harmless warning on every load. +# hosts, between the bundle layers and the user layers. Windows confines +# through the ACL restricted-token runner (the win32 chain of +# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped +# stack is the SANDBOXED PowerShell executor plus the full permission +# surface: sandbox/sandbox-policy enforce the file-effect policy, the +# permission switcher and the approval service run exactly as on POSIX, and +# fs-sandbox fences the in-process filesystem view. Only the POSIX bash +# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. +# A Windows host that prefers the unconfined local pwsh executor or full +# access overrides these rows through its profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-sandbox and +# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor +# families register the same 'bash' service, so re-enabling the bash rows +# while pwsh-sandbox stays inserted fails loud at load on a duplicate +# registration. - id: bash-sandbox disabled: true @@ -28,27 +21,9 @@ - id: tool-bash disabled: true -- id: permission - disabled: true - -- id: ui-permission - disabled: true - -- id: sandbox - disabled: true - -- id: sandbox-policy - disabled: true - -- id: fs-sandbox - disabled: true - -- id: approval - disabled: true - - insert: - - id: pwsh-local - name: '@deepseek-ai/dsh-pwsh-local' + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 2683ab3654..060026e284 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-local", - "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed", + "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", "version": "0.0.1", "private": true, "type": "module", @@ -31,6 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", "schemastery": "^3.18.0" }, diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 64e92d9bf2..22182095d5 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,12 +1,16 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then - * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr classification facts. Missing or unusable - * confinement fails closed rather than returning the original argv. + * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes + * competing candidates once, and reports each wrap's enforcement and stderr + * classification facts. Missing or unusable confinement fails closed rather + * than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, @@ -69,6 +73,27 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean return probe.status === 0 } +/** + * Functional windows-acl probe: run the runner in read-only mode (zero grants, + * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created + * the restricted token and spawned the child under it. The win32 chain is a + * sole candidate, so the product never probes; the probe exists for override + * chains and mirrors the other rungs' shape. + */ +function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean { + const program = runnerInvocation[0] + if (program === undefined) return false + const probe = spawnSync(program, [ + ...runnerInvocation.slice(1), + '--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only', + '--', 'cmd', '/c', 'exit', '0', + ], { + timeout: timeoutMs, + stdio: 'ignore', + }) + return probe.status === 0 +} + /** Test seam: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -85,10 +110,14 @@ export interface SandboxInternals { landlockLauncher?: string /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */ seatbeltExec?: string + /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ + windowsAclRunnerArgs?: string[] + /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ + probeWindowsAcl?: () => boolean } /** The chain's verdict: which runner confines, and how completely it enforces. */ -type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement } +type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } /** * The runner chain per platform — selection is BY PLATFORM first, probes @@ -102,11 +131,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: const PLATFORM_CHAINS: Record = { linux: ['bwrap', 'landlock'], darwin: ['seatbelt'], - // Reserved slot, deliberately empty: Windows support fills it with a confinement runner - // (AppContainer / restricted-token family, shipped from its own repository on the - // landlock-run template) plus a SelectedRunner['runner'] union member — the switches' - // assertNever guards then walk the implementer to every site. - win32: [], + // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl): + // a sole candidate, selected without a probe — its execution-time refusal + // fails closed through its stderr signature (windows-acl-run:) and exit 127. + win32: ['windows-acl'], } /** @@ -122,6 +150,9 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', + // The restricted token intersects every write access by construction, so + // the ACL runner governs every promised file effect — full enforcement. + 'windows-acl': 'full', } /** @@ -144,6 +175,9 @@ const DENIAL_SIGNATURES = { bwrap: ['read-only file system'], landlock: ['permission denied'], seatbelt: ['operation not permitted'], + // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied."; + // node EACCES: "permission denied". + 'windows-acl': ['access is denied', 'access to the path', 'permission denied'], runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record @@ -152,7 +186,9 @@ const DENIAL_SIGNATURES = { * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. - * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * The windows-acl runner prints `windows-acl-run: ` on every + * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ const RUNNER_FAILURE_RULES = { @@ -163,6 +199,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], + 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -245,6 +282,14 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] + case 'windows-acl': return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Explicit, never GetTempPathW-defaulted: the runner grants exactly + // this directory (workspace-write) or nothing (read-only). + '--temp', tmpdir(), + '--mode', policy.mode, + ] default: return assertNever(runner) } } @@ -295,6 +340,11 @@ export class LocalSandboxProvider extends SandboxProvider { const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs)) return probe(this.seatbeltExec()) ? 'full' : 'unusable' } + case 'windows-acl': { + const probe = this.internals.probeWindowsAcl + ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) + return probe() ? 'full' : 'unusable' + } default: return assertNever(runner) } } @@ -308,6 +358,21 @@ export class LocalSandboxProvider extends SandboxProvider { private seatbeltExec(): string { return this.internals.seatbeltExec ?? 'sandbox-exec' } + + /** + * The windows-acl runner argv prefix: the built lib/runner.js entry when + * present (production), else the package source through tsx (development). + * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps + * the same argv contract and only swaps these entries. + */ + private windowsAclRunnerInvocation(): string[] { + const override = this.internals.windowsAclRunnerArgs + if (override !== undefined) return override + const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + if (existsSync(builtEntry)) return [process.execPath, builtEntry] + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] + } } export default LocalSandboxProvider diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 74d4c2a8a1..5e12b37ad2 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -209,13 +209,10 @@ describe('the platform chains', () => { expect(probeSeatbelt).not.toHaveBeenCalled() }) - it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => { - // The slot exists so Windows support is an additive fill-in (chain entry - // + runner union member), never a redesign — and reserving it must not - // weaken the fail-closed end in the meantime. - const { sandbox } = await setup({}, { platform: 'win32' }) - expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) - }) + // The win32 chain's argv contract, denial dialect, and runner-failure rules + // live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts + // (platform-independent assertions that run in every CI lane, including + // Windows where this package's POSIX-only suites are excluded). it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => { const probeBwrap = vi.fn(() => true) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml new file mode 100644 index 0000000000..d1ed2c0d43 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -0,0 +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/sandbox/sandbox-windows-acl/README.md +README.md: 34a9e7160bae3af93da17cae254202bc1b555967 +README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md new file mode 100644 index 0000000000..34a9e7160b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +English | [中文](README.zh.md) + +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). + +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. + +## Usage + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // revokes all standing grants; reports every cleanup failure +``` + +Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. + +## The confinement runner + +The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: + +```sh +node runner.js --workspace --temp --mode -- +``` + +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. + +Modes: +- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. +- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. + +## Header verification + +All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. + +## Verified boundaries (inherent to restricted tokens, not this port) + +- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. +- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. + +## Known Limitations and Deferred Work + +- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md new file mode 100644 index 0000000000..0ecfd146a2 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +[English](README.md) | 中文 + +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 + +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 + +## 用法 + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +``` + +本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 + +## 隔离 runner + +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: + +```sh +node runner.js --workspace --temp --mode -- +``` + +runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 + +模式: +- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 + +## 头文件查证 + +所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 + +## 已验证的边界(受限令牌固有,非本移植缺陷) + +- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 +- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 + +## 已知限制与后续工作 + +- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json new file mode 100644 index 0000000000..3e2afd8fb3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-sandbox-windows-acl", + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./runner": { + "types": "./lib/types/runner.d.ts", + "default": "./lib/runner.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/runner.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts new file mode 100644 index 0000000000..58020c4470 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -0,0 +1,113 @@ +/** + * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with + * the failure handling the POC lacks). Every API call is checked and every + * failure is reported with the API name, the exact Win32 code, the formatted + * system text, and the affected path. + * @module @deepseek-ai/dsh-sandbox-windows-acl/acl + */ + +import { allocPtrSlot, decodePtr, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp): + * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, + * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. + * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which + * removes every ACE for the trustee. + */ +function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { + const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) + entry.writeUInt32LE(permissions, 0) // grfAccessPermissions + entry.writeUInt32LE(mode, 4) // grfAccessMode + entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation + entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm + entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + return entry +} + +/** + * Grant `FILE_GENERIC_WRITE & ~READ_CONTROL` (displays as "Write") to the + * orphan SID on `path`, inheriting to subcontainers and objects. The directory + * must be owned by the caller (owner implicit WRITE_DAC) — same precondition + * as the POC. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), null, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', mergeResult, path) + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `null ACL for ${path}`) + + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + // Free the LocalAlloc'd ACL before any throw; capture both outcomes first. + const freed = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, path) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path})`) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Allocation contract (the POC's RevokeAccess, minus its missing checks): + * GetNamedSecurityInfoW returns the DACL pointer INSIDE the security + * descriptor allocation — only the descriptor may be LocalFree'd, and it must + * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL + * pointer itself corrupts the heap (verified the hard way). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) + const oldAcl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) { + if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + throwWin32(api, 'SetEntriesInAclW', mergeResult, `revokeWrite(${path})`) + } + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) { + if (descriptor !== null) api.localFree(descriptor) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `revokeWrite(${path}): null new ACL`) + } + + // The descriptor block (oldAcl included) is dead after the merge — free it + // before applying, exactly like the POC. + const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + const freedNew = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `revokeWrite(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) new ACL`) + return true +} diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts new file mode 100644 index 0000000000..b57d6dd466 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/errors.ts @@ -0,0 +1,21 @@ +/** + * Fail-closed Win32 error type. Every backend API failure raises this with the + * API name and the exact Win32 code; the original POC silently ignored every + * failed call and would run children UNRESTRICTED (fail-open) — that is the + * failure mode this class exists to prevent. + * @module @deepseek-ai/dsh-sandbox-windows-acl/errors + */ + +export class Win32Error extends Error { + /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ + readonly api: string + /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts new file mode 100644 index 0000000000..a2fefecdbb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -0,0 +1,307 @@ +/** + * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so + * non-Windows processes never open Win32 libraries. Every function signature + * below was verified against the MinGW Windows headers on this machine + * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / + * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); + * struct layouts are asserted at load time against verify/abi-probe.cpp. + * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi + */ + +import koffi from 'koffi' +import { Win32Error } from './errors.ts' +import * as abi from './win32-abi.ts' + +/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ +declare const nativePtr: unique symbol +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** True for NULL pointers, however koffi returns them (null or 0n). */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +type Ptr = ReturnType + +/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +export interface Win32Bindings { + // ---- process / token handles -------------------------------------------- + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + closeHandle(handle: NativePtr): number + // ---- errors / diagnostics ------------------------------------------------ + getLastError(): number + formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number + // ---- memory -------------------------------------------------------------- + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + // ---- SIDs ---------------------------------------------------------------- + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + // ---- token information --------------------------------------------------- + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + // ---- restricted token ---------------------------------------------------- + createRestrictedToken( + existing: NativePtr, flags: number, + disableCount: number, disableSids: null, + deletePrivilegeCount: number, privilegesToDelete: null, + restrictCount: number, restrictingSids: Buffer, + newToken: NativePtr, + ): number + // ---- ACL editing --------------------------------------------------------- + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: null, group: null, dacl: NativePtr | null, sacl: null, + ): number + getNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, + ): number + // ---- environment / io ---------------------------------------------------- + getTempPathW(length: number, buffer: Buffer): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, applicationName: null, commandLine: string, + processAttributes: null, threadAttributes: null, + inheritHandles: number, creationFlags: number, environment: null, + currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, + ): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, buffer: null, size: number, + bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + // ---- job object (runner kill-on-close) ----------------------------------- + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // ---- console ------------------------------------------------------------- + // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): + // the runner survives console Ctrl+C so the child handles its own and the + // runner can clean up grants after the child exits. + setConsoleCtrlHandler(handler: null, add: number): number + getStdHandle(stdHandle: number): NativePtr +} + +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) +} + +/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +export function allocPtrSlot(): NativePtr { + const value: unknown = koffi.alloc(PVOID, 1) + return value as NativePtr +} + +/** Allocate one uint32 slot. */ +export function allocUint32(): NativePtr { + const value: unknown = koffi.alloc('uint32', 1) + return value as NativePtr +} + +/** Write a uint32 value into a slot pointer. */ +export function encodeUint32(slot: NativePtr, value: number): void { + koffi.encode(slot, 'uint32', value) +} + +/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value: unknown = koffi.decode(slot, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Decode a uint32 at a slot pointer. */ +export function decodeUint32(slot: NativePtr): number { + const value: unknown = koffi.decode(slot, 'uint32') + return value as number +} + +/** Decode a UTF-16 string at a pointer. */ +export function decodeStr16(ptr: NativePtr): string { + const value: unknown = koffi.decode(ptr, 'str16') + return value as string +} + +/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +export function ptrAddress(ptr: NativePtr): bigint { + return koffi.address(ptr) +} + +/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +export function allocBytes(length: number): NativePtr { + const value: unknown = koffi.alloc('uint8', length) + return value as NativePtr +} + +/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { + const value: unknown = koffi.decode(buffer, offset, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Allocate a zeroed STARTUPINFOW. */ +export function allocStartupInfo(): NativePtr { + const value: unknown = koffi.alloc(STARTUPINFOW, 1) + return value as NativePtr +} + +/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** Allocate a zeroed PROCESS_INFORMATION. */ +export function allocProcessInfo(): NativePtr { + const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) + return value as NativePtr +} + +/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) + return value as ProcessInfoOutput +} + +let cached: Win32Bindings | undefined + +function bindings(): Win32Bindings { + if (cached !== undefined) return cached + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + + // Each binding shape is verified by verify/abi-probe.cpp against the real + // Windows headers and exercised end-to-end by tests/probe.spec.ts; the + // single cast keeps the per-binding noise out of this table. + const bind = (lib: ReturnType, name: string, result: Ptr | string, args: Array): unknown => + lib.func('__stdcall', name, result, args) + + cached = { + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), + localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), + localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), + convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), + convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), + getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), + copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32Bindings + return cached +} + +/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +export function win32(): Promise { + return Promise.resolve(bindings()) +} + +/** Turn a Win32 error code into readable text via FormatMessageW. */ +export function errorText(api: Win32Bindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, win32Code, 0, buffer, buffer.length / 2, null, + ) + if (length === 0) return '' + return buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately + * after the failed call so GetLastError is not clobbered by other Win32 calls. + */ +export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts new file mode 100644 index 0000000000..77655881ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -0,0 +1,280 @@ +/** + * Windows ACL write-restriction sandbox backend for the DeepSeek Harness + * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ + * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED + * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only + * this sandbox instance adds to the target directories' DACLs — the + * intersection check then allows writes exactly where that SID has a Write + * ACE, and nowhere else. Unlike the POC, every API failure throws with the + * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * + * Known boundaries (inherent to restricted tokens, not this port): + * - writes are restricted; reads, network, and process visibility are NOT + * (WRITE_RESTRICTED intersects only write accesses); + * - console isolation is unavailable — children share the host console + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with + * STATUS_DLL_INIT_FAILED under the restriction); + * - the temp directory and every writable directory must be owned by the + * caller (owner-implicit WRITE_DAC); + * - grants are standing ACE mutations on real directories — revoke them via + * dispose() before the process exits (the POC's documented + * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use + * this module's revoke instead). + * @module @deepseek-ai/dsh-sandbox-windows-acl + */ + +import { randomInt } from 'node:crypto' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { grantWrite, revokeWrite } from './acl.ts' +import { Win32Error } from './errors.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' +import * as abi from './win32-abi.ts' + +export { quoteArg } from './spawn.ts' +export { Win32Error } from './errors.ts' + +export interface AclSandboxOptions { + /** Directories the confined child may write into (must exist and be caller-owned). */ + writableDirs: readonly string[] + /** + * Temp directory to also grant; defaults to GetTempPathW() at init time. + * Pass null for read-only confinement: NO temp grant (strict zero write + * allowance — not even the NUL device is writable, see README). + */ + tempDir?: string | null + /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + writeSid?: string +} + +export interface AclSandboxSpawnOptions { + /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ + command: string + /** Arguments, quoted per CommandLineToArgvW rules. */ + args?: readonly string[] + /** Working directory; defaults to the caller's cwd. */ + cwd?: string + /** + * 'pipe' (default): capture stdout/stderr via anonymous pipes. + * 'inherit': the child inherits the caller's stdio directly (runner usage — + * bytes flow straight through), always wrapped in a kill-on-close job so the + * child dies with the caller; stdout/stderr in the result are empty. + */ + stdio?: 'pipe' | 'inherit' +} + +export interface AclSandboxChildResult { + stdout: Buffer + stderr: Buffer + exitCode: number +} + +export interface AclSandboxChild { + /** Child process id. */ + pid: number + /** Resolve stdout/stderr and the exit code once the child exits. */ + wait(): Promise +} + +function randomWriteSid(): string { + return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` +} + +function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + return buffer.subarray(0, length * 2).toString('utf16le') +} + +/** + * One write-restricted sandbox instance: token + orphan-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes whatever was granted + * and throws; `dispose()` revokes all grants and reports every cleanup + * failure. + */ +export class AclSandbox { + readonly writableDirs: string[] + readonly writeSid: string + private readonly tempDirOption: string | null | undefined + private tempDirResolved: string | null | undefined + private api: Win32Bindings | undefined + private token: NativePtr | undefined + private writeSidPtr: NativePtr | undefined + private grantedPaths: string[] = [] + + constructor(options: AclSandboxOptions) { + this.writableDirs = options.writableDirs.map((directory) => { + const absolute = resolve(directory) + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`) + } + return absolute + }) + this.tempDirOption = options.tempDir + this.writeSid = options.writeSid ?? randomWriteSid() + } + + /** Resolved temp directory (available after init; null when temp grants are disabled). */ + get tempDir(): string | null | undefined { + return this.tempDirResolved + } + + /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + async init(): Promise { + if (this.api !== undefined) throw new Error('AclSandbox is already initialized') + const api = await win32() + + const currentToken = openCurrentProcessToken(api) + try { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + const writeSidPtr = parsedSid + + const tempDir = this.tempDirOption === null + ? null + : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + if (tempDir !== null) { + if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { + throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) + } + this.tempDirResolved = tempDir + } + + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } + const logonSid = findLogonSid(api, currentToken) + const restricted = createRestrictedToken( + api, currentToken, logonSid, writeSidPtr, + { + world: makeWellKnownSid(api, abi.WinWorldSid), + authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), + interactive: makeWellKnownSid(api, abi.WinInteractiveSid), + local: makeWellKnownSid(api, abi.WinLocalSid), + }, + ) + this.token = restricted + if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + this.api = api + } catch (error) { + // Best-effort close on the failure path (last error already captured in `error`). + api.closeHandle(currentToken) + // Fail-closed cleanup: never leave standing grants behind a failed init. + const cleanupFailures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + ) + } + throw error + } + } + + /** + * Spawn a process under the restricted token. Fails closed: throws on every + * Win32 failure; the child is never created unrestricted. With + * `stdio: 'inherit'` the child shares the caller's stdio directly and is + * placed in a kill-on-close job (dies with the caller). Call dispose() only + * after all children have exited — revoking grants under a live child + * removes its remaining write allowance. + */ + spawn(options: AclSandboxSpawnOptions): AclSandboxChild { + const api = this.api + const token = this.token + if (api === undefined || token === undefined) throw new Error('AclSandbox is not initialized: call init() first') + const args = options.args ?? [] + const cwd = options.cwd ?? process.cwd() + + if (options.stdio === 'inherit') { + const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, + } + } + + const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, + } + } + + /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + dispose(): void { + const api = this.api + if (api === undefined) return + const failures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = api.localFree(writeSidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + } + const token = this.token + if (token !== undefined) { + try { + if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') + } catch (error) { + failures.push(error) + } + } + this.api = undefined + this.token = undefined + this.writeSidPtr = undefined + this.grantedPaths = [] + if (failures.length > 0) { + throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts new file mode 100644 index 0000000000..35ea265a4b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`. + * @module @deepseek-ai/dsh-sandbox-windows-acl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-windows-acl-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond the fail-closed contracts it enforces at each + * Win32 call boundary. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts new file mode 100644 index 0000000000..02fd416f7f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -0,0 +1,135 @@ +/** + * The windows-acl confinement runner: the argv-prefix wrapper the sandbox + * seam spawns in place of the caller's command. It creates the + * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped + * argv under it with the CALLER'S stdio inherited (bytes flow straight + * through), mirrors the child's exit code, and revokes all grants on exit. + * + * Stable argv contract (the seam builds it; a native-exe replacement would + * keep the same contract): + * [node, runner.js, '--workspace', , '--temp', , + * '--mode', , '--', ] + * + * Modes: + * - workspace-write: the workspace and temp directories carry the orphan-SID + * Write grant; every other write is denied by the token intersection. + * - read-only: STRICT zero grants — no directory is writable, not even the + * NUL device (`> $null` fails with access denied); documented in README. + * + * Failure contract: every runner-side failure (bad args, missing + * directories, token/grant/spawn errors) prints `windows-acl-run: ` + * to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that + * signature. The child is NEVER spawned unrestricted. + * @module @deepseek-ai/dsh-sandbox-windows-acl/runner + */ + +import { existsSync, statSync } from 'node:fs' + +import { win32 } from './ffi.ts' +import { AclSandbox } from './index.ts' + +const RUNNER_SIGNATURE = 'windows-acl-run' +const RUNNER_FAILURE_EXIT = 127 + +class RunnerFailure extends Error {} + +/** Print the runner-failure signature line and unwind. */ +function fail(detail: string): never { + process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`) + throw new RunnerFailure(detail) +} + +interface ParsedArgs { + workspace: string + temp: string + mode: 'read-only' | 'workspace-write' + command: string + args: string[] +} + +function parseArgs(raw: string[]): ParsedArgs { + let workspace: string | undefined + let temp: string | undefined + let mode: string | undefined + let index = 0 + for (; index < raw.length; index++) { + const token = raw[index] + if (token === '--') { + index++ + break + } + index++ + const value = raw[index] + if (value === undefined) fail(`missing value after ${token}`) + switch (token) { + case '--workspace': workspace = value; break + case '--temp': temp = value; break + case '--mode': mode = value; break + default: fail(`unknown argument: ${token}`) + } + } + if (workspace === undefined) fail('missing --workspace') + if (temp === undefined) fail('missing --temp') + if (mode !== 'read-only' && mode !== 'workspace-write') fail(`unknown mode: ${String(mode)}`) + const argv = raw.slice(index) + const command = argv[0] + if (command === undefined) fail('missing command after --') + return { workspace, temp, mode, command, args: argv.slice(1) } +} + +function requireDirectory(label: string, path: string): void { + if (!existsSync(path) || !statSync(path).isDirectory()) { + fail(`${label} is not an existing directory: ${path}`) + } +} + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)) + // Both directories are validated in both modes: a provider bug that passes + // a bogus root must fail loudly at the runner boundary, never mid-child. + requireDirectory('--workspace', parsed.workspace) + requireDirectory('--temp', parsed.temp) + + const api = await win32() + // Ignore this process's own CTRL+C: the confined child (same console) keeps + // handling its own; the runner must survive to revoke grants and mirror the + // child's exit code. + if (api.setConsoleCtrlHandler(null, 1) === 0) { + fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) + } + + const sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + }) + await sandbox.init() + + try { + const child = sandbox.spawn({ + command: parsed.command, + args: parsed.args, + stdio: 'inherit', + }) + const result = await child.wait() + return result.exitCode + } finally { + // Cleanup failures must not mask the child's exit code: report and keep going. + try { + sandbox.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } +} + +main().then( + (exitCode) => { + process.exitCode = exitCode + }, + (error: unknown) => { + if (!(error instanceof RunnerFailure)) { + process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`) + } + process.exitCode = RUNNER_FAILURE_EXIT + }, +) diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts new file mode 100644 index 0000000000..58571257a1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -0,0 +1,296 @@ +/** + * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with + * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then + * asynchronous pipe draining and exit waiting. Console isolation + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this + * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED + * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is + * pipe-based and unaffected; the child shares the host console. + * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn + */ + +import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Quote one argument per the CommandLineToArgvW parsing rules (backslash + * escaping only before quotes; a trailing backslash before the closing quote + * is doubled). + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes++ + index++ + } + if (index < argument.length && argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '') + } + } + return quoted + '"' +} + +/** Build the single command line CreateProcess parses from program + argv. */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function createPipe(api: Win32Bindings): PipePair { + const readSlot = allocPtrSlot() + const writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') + return { read, write } +} + +function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } +} + +export interface SpawnedNative { + pid: number + process: NativePtr + stdoutRead: NativePtr + stderrRead: NativePtr +} + +/** + * Create a process under the restricted token with piped stdio. The child's + * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends + * are returned for draining. + */ +export function spawnSandboxed( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedNative { + const stdIn = createPipe(api) + const stdOut = createPipe(api) + const stdErr = createPipe(api) + // Child side of each pipe must be inheritable (POC lines 262-268). + setInheritable(api, stdIn.read, 'stdin read end') + setInheritable(api, stdOut.write, 'stdout write end') + setInheritable(api, stdErr.write, 'stderr write end') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: required for redirection + 0, // no creation flags: suspended/no-window variants are unusable under the restriction + null, options.cwd, + startupInfo, processInfo, + ) + // Capture the failure before CloseHandle calls clobber GetLastError. + if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`) + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + // Host-side cleanup: child handles are now duplicated in the child; the + // host closes its copies so ReadFile sees EOF when the child exits. + api.closeHandle(stdIn.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.write) + api.closeHandle(stdIn.write) + api.closeHandle(threadHandle) + + return { + pid: info.dwProcessId, + process: processHandle, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } +} + +/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { + const chunks: Buffer[] = [] + for (;;) { + const bytesReadSlot = allocUint32() + const totalAvailSlot = allocUint32() + const leftThisMessageSlot = allocUint32() + const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(totalAvailSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + const readSlot = allocUint32() + if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(readSlot))) + } + await new Promise(resolve => setImmediate(resolve)) + } + api.closeHandle(handle) + return Buffer.concat(chunks) +} + +/** + * Wait for process exit and return its exit code. Call only after both drains + * have resolved — the drains finish when the child closed its pipe ends, i.e. + * the child has already exited, so this wait returns immediately. Calling it + * earlier would block the event loop and starve the drains (the pipe-buffer + * deadlock the POC comments warn about). + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + const waitResult = api.waitForSingleObject(process, abi.INFINITE) + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + api.closeHandle(process) + return decodeUint32(exitCodeSlot) +} + +/** + * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at + * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout + * verified by abi-probe.cpp). When the caller dies with the job handle open, + * Windows terminates every process in the job — the orphan-child backstop. + * The caller keeps the returned handle open for the child's lifetime. + */ +function createKillOnCloseJob(api: Win32Bindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) + if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +export interface SpawnedInherited { + pid: number + process: NativePtr + /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ + job: NativePtr +} + +/** + * Create a process under the restricted token whose stdio passes straight + * through to the caller's pipes. This is the runner shape: the harness spawns + * the runner with piped stdio, and the runner's confined child writes to + * those same pipes. + * + * Node clears the inheritability of its stdio handles at startup + * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit + * bit around the call (libuv instead duplicates the handles; re-enabling is + * equivalent here and cheaper) and pass them explicitly via + * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles + * ("The handle is invalid", verified the hard way). The child starts + * suspended so it can be assigned to a kill-on-close job before it runs. + */ +export function spawnSandboxedInherited( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedInherited { + const job = createKillOnCloseJob(api) + const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) + const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) + const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) + if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { + api.closeHandle(job) + throwLastError(api, 'GetStdHandle', 'null standard handle') + } + + const makeInheritable = (handle: NativePtr, label: string): void => { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + } + const restoreInherit = (handle: NativePtr): void => { + // Best-effort hygiene: the runner spawns nothing else; failures here must + // not mask the child outcome, so the result is deliberately unchecked. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + makeInheritable(stdIn, 'stdin') + makeInheritable(stdOut, 'stdout') + makeInheritable(stdErr, 'stderr') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: the re-enabled std handles must be inheritable + abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution + null, options.cwd, + startupInfo, processInfo, + ) + restoreInherit(stdIn) + restoreInherit(stdOut) + restoreInherit(stdErr) + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + if (api.assignProcessToJobObject(job, processHandle) === 0) { + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`) + api.closeHandle(threadHandle) + + return { pid: info.dwProcessId, process: processHandle, job } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts new file mode 100644 index 0000000000..e1c0f2dc2a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -0,0 +1,138 @@ +/** + * Restricted-token construction: open the current process token, extract its + * logon SID, build the well-known SIDs, and call CreateRestrictedToken with + * the POC's restricting-SID allowlist. Every API call is checked; any failure + * throws with the API name and the exact Win32 code — the original POC ignored + * all of these and silently ran children with the FULL, unrestricted token. + * @module @deepseek-ai/dsh-sandbox-windows-acl/token + */ + +import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Open the current process's access token with the rights + * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token + * handle is obtained through a real OpenProcess handle because the + * GetCurrentProcess() pseudo-handle is not addressable through koffi). + */ +export function openCurrentProcessToken(api: Win32Bindings): NativePtr { + const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) + if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`) + + const tokenSlot = allocPtrSlot() + const opened = api.openProcessToken( + processHandle, + abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY, + tokenSlot, + ) + if (opened === 0) { + const win32Code = api.getLastError() + api.closeHandle(processHandle) // best-effort on the error path + throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`) + } + if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle') + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle') + return token +} + +/** + * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute + * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and + * other per-logon objects; the POC extracts it the same way. + */ +export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query') + if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`) + + const groups = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenGroups') + } + const groupCount = groups.readUInt32LE(0) + for (let index = 0; index < groupCount; index++) { + const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE) + const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8) + // >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set. + const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0) + if (sidPtr === null || !isLogonId) continue + const sidLength = api.getLengthSid(sidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`) + const copy = allocBytes(sidLength) + if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`) + return copy + } + throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) +} + +/** Create one well-known SID (68-byte buffer) and assert its validity. */ +export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { + const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) + const sizeSlot = allocUint32() + encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE) + if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) { + throwLastError(api, 'CreateWellKnownSid', `type ${type}`) + } + if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`) + return sid +} + +/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ +function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { + const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) + sids.forEach((sid, index) => { + buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index) + }) + return buffer +} + +export interface RestrictingSidSet { + world: NativePtr + authUser: NativePtr + interactive: NativePtr + local: NativePtr +} + +/** + * Create the write-restricted token. Ordering matters: EVERYONE first (the + * POC's note — the intersection check hits it on most objects), then the + * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan + * write SID that forms the write allowlist. S-1-2-1 (console logon) is + * intentionally absent: see win32-abi.ts for the verified failure modes. + * FAILS CLOSED: any failure throws — never spawn unrestricted. + */ +export function createRestrictedToken( + api: Win32Bindings, + currentToken: NativePtr, + logonSid: NativePtr, + writeSid: NativePtr, + known: RestrictingSidSet, +): NativePtr { + const restrictingSids = buildRestrictingSids([ + known.world, + logonSid, + known.authUser, + known.interactive, + known.local, + writeSid, + ]) + const tokenSlot = allocPtrSlot() + const created = api.createRestrictedToken( + currentToken, + abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED, + 0, null, // no SIDs disabled + 0, null, // no privileges deleted + restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE, + restrictingSids, + tokenSlot, + ) + if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`) + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle') + return token +} diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts new file mode 100644 index 0000000000..97e5322bbf --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -0,0 +1,136 @@ +/** + * Windows ABI constants for the ACL-sandbox backend. + * + * Every value was verified against the actual MinGW Windows headers on this + * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at + * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). + * Regenerate the probe with: + * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe + * + * The port intentionally excludes two pieces of the original POC + * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified + * empirically on Windows 11 build 26200: + * - S-1-2-1 (console logon SID) in the restricting list: the POC created it + * via CreateWellKnownSid(WinLocalLogonSid) which fails here with + * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes + * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the + * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child + * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever + * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. + * - Console isolation: under this restriction scheme a hidden console is not + * attainable, so children share the host console (stdio redirection is + * pipe-based and unaffected). + * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi + */ + +// ---- winnt.h --------------------------------------------------------------- + +// TOKEN_* access rights (winnt.h lines ~3928) +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +export const TOKEN_DUPLICATE = 0x0002 +export const TOKEN_QUERY = 0x0008 +export const TOKEN_ADJUST_DEFAULT = 0x0080 + +// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +export const SE_GROUP_LOGON_ID = 0xC0000000 + +// Generic file access (winnt.h lines ~5893-5913): +// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES +// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +export const FILE_GENERIC_WRITE = 0x00120116 +// What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 + +// CreateRestrictedToken flags (winnt.h lines ~4284) +export const DISABLE_MAX_PRIVILEGE = 0x1 +export const LUA_TOKEN = 0x4 +export const WRITE_RESTRICTED = 0x8 + +// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +export const WinWorldSid = 1 +export const WinLocalSid = 2 +export const WinInteractiveSid = 11 +export const WinAuthenticatedUserSid = 17 + +// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +export const TokenGroups = 2 + +// SECURITY_INFORMATION (winnt.h line ~4293) +export const DACL_SECURITY_INFORMATION = 0x00000004 + +// PROCESS access rights (winnt.h lines ~4364) +export const PROCESS_QUERY_INFORMATION = 0x0400 + +// ---- accctrl.h ------------------------------------------------------------- + +// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +export const SE_FILE_OBJECT = 1 + +// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +export const TRUSTEE_IS_UNKNOWN = 0 +export const TRUSTEE_IS_SID = 0 +export const NO_MULTIPLE_TRUSTEE = 0 + +// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +export const GRANT_ACCESS = 1 +export const REVOKE_ACCESS = 4 + +// grfInheritance (accctrl.h lines ~137-142) +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + +// ---- winbase.h ------------------------------------------------------------- + +export const STARTF_USESTDHANDLES = 0x00000100 +export const HANDLE_FLAG_INHERIT = 0x1 +export const INFINITE = 0xFFFFFFFF +export const MAX_PATH = 260 +// winbase.h line ~410: the confined child starts suspended so the runner can +// assign it to the kill-on-close job before any of its code runs. +export const CREATE_SUSPENDED = 0x4 +// winbase.h lines ~497-499: GetStdHandle selectors. +export const STD_INPUT_HANDLE = -10 +export const STD_OUTPUT_HANDLE = -11 +export const STD_ERROR_HANDLE = -12 + +// FormatMessageW flags (winbase.h lines ~1446-1469) +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 + +// ---- error codes ----------------------------------------------------------- + +export const ERROR_SUCCESS = 0 +export const ERROR_INSUFFICIENT_BUFFER = 122 +export const ERROR_BROKEN_PIPE = 109 +export const ERROR_NO_DATA = 232 + +// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- + +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last +// job handle closes — the orphan-child backstop for the runner design. +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +export const JobObjectExtendedLimitInformation = 9 +// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION +// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), +// verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 + +// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- + +export const SECURITY_MAX_SID_SIZE = 68 +/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +export const SID_AND_ATTRIBUTES_SIZE = 16 +/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +export const TOKEN_GROUPS_OFFSET = 8 +/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +export const EXPLICIT_ACCESS_W_SIZE = 48 +/** Trustee offset inside EXPLICIT_ACCESS_W. */ +export const TRUSTEE_W_OFFSET = 16 +/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +export const STARTUPINFOW_SIZE = 104 +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts new file mode 100644 index 0000000000..e370316b79 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -0,0 +1,97 @@ +/** + * End-to-end probe of the ACL write-restriction sandbox, using the same + * probes as the POC verification harness: the confined child must be able to + * write into the granted target and temp directories, must be DENIED writing + * anywhere else, and (documented boundary) may still READ outside — the + * WRITE_RESTRICTED token intersects write accesses only. + * + * The escape target sits in its own scratch dir under the system temp + * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never + * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the + * whole real temp tree) and the writable dir is a separate mkdtemp directory + * that contains neither sibling. Nothing under the user profile is touched. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { AclSandbox } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let sandbox: AclSandbox + + beforeAll(async () => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + // tempDir is passed explicitly: GetTempPathW reads the native environment + // block, which host runtimes (vitest worker pools) may not keep in sync + // with process.env — and a real-temp grant would inherit over every + // temp subdirectory, including this test's scratch dir. + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + await sandbox.init() + }) + + afterAll(() => { + sandbox.dispose() + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('allows writes only in granted directories and denies the escape write', async () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const child = sandbox.spawn({ + command: 'pwsh', + args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe], + cwd: writableDir, + }) + const result = await child.wait() + const output = result.stdout.toString('utf8') + result.stderr.toString('utf8') + + expect(result.exitCode, `child output:\n${output}`).toBe(0) + expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED') + // Documented boundary: WRITE_RESTRICTED intersects write accesses only, + // so reads outside the allowlist still succeed. + expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { + // A malformed SID makes ConvertStringSidToSidW fail; init must throw + // before any grant is applied and never spawn unrestricted. + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts new file mode 100644 index 0000000000..f2737948ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -0,0 +1,58 @@ +/** + * The win32 chain's argv contract, denial dialect, and runner-failure rules, + * exercised through the REAL LocalSandboxProvider.confine() with an injected + * platform and runner argv prefix. Platform-independent assertions: they run + * in every CI lane (Windows included, where sandbox-local's own POSIX-only + * suites are excluded) — the end-to-end runner behavior lives in + * runner.spec.ts on win32 hosts. + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' + +const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } +const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + +async function setup(internals: LocalSandboxProvider['internals']) { + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = internals + return sandbox +} + +describe('windows-acl win32 chain (LocalSandboxProvider)', () => { + it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const sandbox = await setup({ + platform: 'win32', + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + probeWindowsAcl, + }) + const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + // A sole candidate is selected unprobed. + expect(probeWindowsAcl).not.toHaveBeenCalled() + }) + + it('read-only: same runner and contract, read-only mode flag', async () => { + const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts new file mode 100644 index 0000000000..f188423425 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -0,0 +1,107 @@ +/** + * End-to-end runner tests: spawn the REAL runner entry through tsx (exactly + * the argv shape dsh-sandbox-local's confine() builds), with piped stdio + * inherited through the runner into the confined child — the same chain a + * production confined execution walks. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const isWin32 = process.platform === 'win32' +const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function runRunner(args: string[], timeoutMs = 30_000) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], { + timeout: timeoutMs, + encoding: 'utf8', + }) +} + +describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + + beforeAll(() => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('workspace-write: the confined child writes granted directories only', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: OK') + expect(result.stdout).toContain('TEMP-WRITE: OK') + expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + // The NUL device is a securable object: strict zero grants deny it too. + 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', + // PowerShell's $null redirection discards without opening NUL — must keep working. + 'echo hi > $null;\'DOLLAR-NULL: OK\';', + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: DENIED') + expect(result.stdout).toContain('TEMP-WRITE: DENIED') + expect(result.stdout).toContain('NUL-WRITE: DENIED') + expect(result.stdout).toContain('DOLLAR-NULL: OK') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) + }, 30_000) + + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { + const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) + expect(result.status).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json new file mode 100644 index 0000000000..e882ed2d72 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts new file mode 100644 index 0000000000..7de4ede1e1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +// The confinement runner builds as its own entry (path-loaded by +// dsh-sandbox-local's win32 chain), inlining the sandbox primitives while +// koffi stays an external native require — the same shape as +// directory-picker-native's worker entry. +export default defineConfig({ + entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp new file mode 100644 index 0000000000..914326acdc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -0,0 +1,177 @@ +// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows +// headers on this machine. These numbers are the source of truth for the +// koffi FFI definitions in the Node.js port. +#include +#include +#include +#include +#include + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(DWORD)); + P(sizeof(WORD)); + P(sizeof(BOOL)); + + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, cb)); + P(offsetof(STARTUPINFOW, lpReserved)); + P(offsetof(STARTUPINFOW, lpDesktop)); + P(offsetof(STARTUPINFOW, lpTitle)); + P(offsetof(STARTUPINFOW, dwX)); + P(offsetof(STARTUPINFOW, dwY)); + P(offsetof(STARTUPINFOW, dwXSize)); + P(offsetof(STARTUPINFOW, dwYSize)); + P(offsetof(STARTUPINFOW, dwXCountChars)); + P(offsetof(STARTUPINFOW, dwYCountChars)); + P(offsetof(STARTUPINFOW, dwFillAttribute)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, wShowWindow)); + P(offsetof(STARTUPINFOW, cbReserved2)); + P(offsetof(STARTUPINFOW, lpReserved2)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(offsetof(PROCESS_INFORMATION, dwThreadId)); + + P(sizeof(SECURITY_ATTRIBUTES)); + P(offsetof(SECURITY_ATTRIBUTES, nLength)); + P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); + P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); + + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, pMultipleTrustee)); + P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); + P(offsetof(TRUSTEE_W, TrusteeForm)); + P(offsetof(TRUSTEE_W, TrusteeType)); + P(offsetof(TRUSTEE_W, ptstrName)); + + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); + P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Sid)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, GroupCount)); + P(offsetof(TOKEN_GROUPS, Groups)); + + P(sizeof(TOKEN_MANDATORY_LABEL)); + + P(sizeof(SID)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(SID_REVISION); + + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + + P(SE_GROUP_LOGON_ID); + P(SE_GROUP_INTEGRITY); + P(SE_GROUP_INTEGRITY_ENABLED); + + P(FILE_GENERIC_WRITE); + P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); + P(STANDARD_RIGHTS_WRITE); + + P(DISABLE_MAX_PRIVILEGE); + P(SANDBOX_INERT); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + + P((int)WinWorldSid); + P((int)WinLocalSid); + P((int)WinInteractiveSid); + P((int)WinAuthenticatedUserSid); + P((int)WinLocalLogonSid); + P((int)WinConsoleLogonSid); + + P((int)TokenUser); + P((int)TokenGroups); + P((int)TokenIntegrityLevel); + + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)NOT_USED_ACCESS); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(OBJECT_INHERIT_ACE); + P(CONTAINER_INHERIT_ACE); + + P(CREATE_SUSPENDED); + P(CREATE_NO_WINDOW); + P(DETACHED_PROCESS); + P(CREATE_NEW_CONSOLE); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + + P(LMEM_FIXED); + P(LMEM_ZEROINIT); + P(LPTR); + + P(FORMAT_MESSAGE_ALLOCATE_BUFFER); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(MAX_PATH); + + P(ERROR_SUCCESS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_NO_MORE_ITEMS); + P(ERROR_INVALID_PARAMETER); + P(ERROR_INVALID_SID); + P(ERROR_NONE_MAPPED); + P(ERROR_BROKEN_PIPE); + + // Job object (runner kill-on-close hardening) + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); + P(sizeof(IO_COUNTERS)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + // static assertions for the values the koffi module will hardcode + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "grant mask"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6bd0e971..b55e1a2bcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,36 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/pwsh-sandbox: + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../../sandbox/sandbox-windows-acl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -4457,6 +4487,9 @@ importers: packages/sandbox/sandbox-local: dependencies: + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../sandbox-windows-acl node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -4502,6 +4535,22 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/sandbox/sandbox-windows-acl: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../sandbox-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/sdk/create-sdk: dependencies: '@deepseek-ai/dsh-helper': diff --git a/tsconfig.host.json b/tsconfig.host.json index 79276dfe7e..8e6f3789b7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -164,10 +164,12 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/pwsh-sandbox" }, { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, + { "path": "./packages/sandbox/sandbox-windows-acl" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4aaf7258e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,16 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Windows-only packages: their sources execute exclusively on win32 (koffi +// loads Win32 libraries), so the Linux coverage lane can never cover them. +// The Windows dev/CI lane exercises them through the probe/runner suites; the +// per-file 100% gate must not fail on their Linux-uncovered paths. +const windowsOnlyCoverageExclusions = process.platform !== 'win32' + ? [ + 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + ] + : [] + // Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites // self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -221,6 +231,7 @@ export default defineConfig({ 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 94991499c8de1be68ab14ee0eb33c6ac466c3eef Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 01:36:34 +0800 Subject: [PATCH 13/62] docs(pwsh-sandbox): real temp grant is a backend-defined choice aligned with Landlock, not deferred work --- packages/bash/pwsh-sandbox/README.i18n.yaml | 4 ++-- packages/bash/pwsh-sandbox/README.md | 4 ++-- packages/bash/pwsh-sandbox/README.zh.md | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index 7110763e72..5de8fc8e5f 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md -README.md: 5eaa513b7802fe1b4411a5c0bafabafd232d4da7 -README.zh.md: 4924feed26cb7bc50d4863a063d9a4b0bf26d954 +README.md: 222c61176b71042bfcb539a839507a425a1fb9e5 +README.zh.md: 02326bc36c9a7e1fe41817b9ab782e31a6d0b9b9 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index 5eaa513b78..222c61176b 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -17,8 +17,8 @@ The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. -## Known Limitations and Deferred Work +## Known Limitations - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. -- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`), mirroring Landlock's `/tmp` grant — a per-run private temp would need an env-block rewrite in the runner and is deferred. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. - **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index 4924feed26..02326bc36c 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 @@ -17,8 +17,8 @@ 模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 -## 已知限制与后续工作 +## 已知限制 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 -- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 - **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 From 757476981890662849c0f703c855dc75ac0d5102 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:11:15 +0800 Subject: [PATCH 14/62] =?UTF-8?q?fix(bundle):=20drop=20fs-local=20from=20t?= =?UTF-8?q?he=20Windows=20layer=20=E2=80=94=20duplicate=20ctx.fs=20registr?= =?UTF-8?q?ation=20failed=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows platform layer re-enables the base fs-sandbox row (removing its disable), but still inserted dsh-fs-local: both extend FileSystem and provide ctx.fs, so every shipped win32 profile failed at load. Delete the insert; fs-sandbox stays the single fs provider exactly as on POSIX. Sync the roster specs, the base/reference README pairs, the sandbox core doc (read-only grants no sink on Windows), the windows-shell JSDoc, and re-record the i18n pairings. --- apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/windows-shell.ts | 6 ++-- apps/cli/tests/windows-shell.spec.ts | 34 +++++++++++-------- docs/core-data-structures/sandbox.i18n.yaml | 4 +-- docs/core-data-structures/sandbox.md | 2 +- docs/core-data-structures/sandbox.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +-- packages/bundle/base/README.md | 4 +-- packages/bundle/base/README.zh.md | 4 +-- packages/bundle/base/tests/base.spec.ts | 2 +- packages/bundle/base/windows.cordis.patch.yml | 7 ++-- 13 files changed, 41 insertions(+), 36 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 29cdfac304..22ed72dfb9 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 07b162517b90215a6552810124f15c435144f7d4 -README.zh.md: 80030707474e59e76583ed7edc5ddce56334cc03 +README.md: 8264e2e9f57a6687a1cddae15012324790b555ad +README.zh.md: 84bd37e43a982a307a2df8045f7f3703e5b9a8b4 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 07b162517b..8264e2e9f5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -51,7 +51,7 @@ Process shutdown gives the plugin tree up to five seconds to dispose. The first All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the Windows platform layer removes the permission, sandbox, and approval rows entirely: the pwsh shell and the fs tools run unconfined with no workspace boundary (Windows has no OS sandbox runner — landlock/bwrap/seatbelt are POSIX-only — so the shipped posture is honest danger-full-access rather than a boundary the shell could bypass), and `DSH_PERMISSION_MODE` and stored permission settings have no effect there. +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the same permission surface runs over the Windows ACL restricted-token runner: the pwsh executor and the fs tools enforce the workspace boundary through `@deepseek-ai/dsh-sandbox-windows-acl` exactly as on POSIX (the Windows `workspace-write` grant is the workspace plus the real temp directory; `read-only` grants nothing). `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 8003070747..84bd37e43a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -51,7 +51,7 @@ dsh web --dump-config 所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,Windows 平台层会整体移除 permission、sandbox 与 approval 行:pwsh shell 与 fs 工具不受限运行,不存在 workspace 边界(Windows 上没有 OS 级 sandbox runner——landlock/bwrap/seatbelt 均为 POSIX 专属——因此交付姿态是诚实的 danger-full-access,而不是一个 shell 可以绕过的边界),`DSH_PERMISSION_MODE` 与存储的权限设置在彼处也不生效。 +新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,同一权限面运行在 Windows ACL 受限令牌 runner 之上:pwsh 执行器与 fs 工具通过 `@deepseek-ai/dsh-sandbox-windows-acl` 执行与 POSIX 完全一致的 workspace 边界(Windows 的 `workspace-write` 授权是工作区加真实 temp 目录;`read-only` 不授予任何写入)。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 425699ef4f..6bdca7b741 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -1,8 +1,8 @@ /** * The Windows shell platform layer: on win32 hosts the shipped profile - * compositions swap the POSIX-only bash stack for the PowerShell stack - * (`@deepseek-ai/dsh-pwsh-local` + `@deepseek-ai/dsh-tool-pwsh`), matching - * the Windows-pwsh-default roadmap. The layer is the base bundle's + * compositions swap the POSIX-only bash stack for the sandbox-confined + * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + + * `@deepseek-ai/dsh-tool-pwsh`), matching the Windows-pwsh-default roadmap. The layer is the base bundle's * `windows.cordis.patch.yml`, injected by the launcher between the bundle * layers and the user layers so a user patch can still override it — the * only override channel is composition config, like every other roster diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 80ba40cc34..15f5e0521b 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -14,8 +14,8 @@ import { const WINDOWS_PATCH = `- id: bash-sandbox disabled: true - insert: - - id: pwsh-local - name: '@deepseek-ai/dsh-pwsh-local' + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' ` /** One fake bundle layer rooted in a temp directory. */ @@ -48,7 +48,7 @@ describe('resolveWindowsShellLayer', () => { expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) expect(layer?.patches).toEqual([ { id: 'bash-sandbox', disabled: true }, - { insert: [{ id: 'pwsh-local', name: '@deepseek-ai/dsh-pwsh-local' }] }, + { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] }, ]) }) @@ -73,7 +73,7 @@ describe('the shipped Windows composition (real bundle layers)', () => { // suite composes the shipped patch files, not test fixtures. const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) - it('composes the win32 danger-full-access roster through the real patch layers', () => { + it('composes the win32 confined roster through the real patch layers', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) @@ -85,19 +85,24 @@ describe('the shipped Windows composition (real bundle layers)', () => { message => warnings.push(message), ) const byId = new Map(rows.map(row => [row.id, row])) - for (const id of ['bash-sandbox', 'tool-bash', 'permission', 'ui-permission', - 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + // Only the POSIX bash stack leaves the roster: the permission surface + // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled + // exactly as on POSIX — the confined pwsh executor is what changes. + for (const id of ['bash-sandbox', 'tool-bash']) { expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) } - for (const id of ['pwsh-local', 'tool-pwsh', 'fs-local']) { + for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true) + } + for (const id of ['pwsh-sandbox', 'tool-pwsh']) { expect(byId.has(id), `inserted row ${id}`).toBe(true) } - // The web-app layer provides ui-permission, so the full web profile - // composes without any no-match warning. + // The patch touches only base-owned rows plus inserts, so the full web + // profile composes without any no-match warning. expect(warnings).toEqual([]) }) - it('leaves POSIX untouched and base-only profiles warned but harmless', () => { + it('leaves POSIX untouched and base-only profiles compose without warnings', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) @@ -106,10 +111,11 @@ describe('the shipped Windows composition (real bundle layers)', () => { const posixById = new Map(posixRows.map(row => [row.id, row])) expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) expect(posixById.has('pwsh-local')).toBe(false) + expect(posixById.has('pwsh-sandbox')).toBe(false) - // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): - // ui-permission has no row to patch, so the shipped layer warns once per - // composition — never fails — exactly as its header comment documents. + // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the + // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its + // inserts, so the composition produces no no-match warning. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) const baseOnly = loadProfile('dsh', 'base-only', anchor, home) const baseWarnings: string[] = [] @@ -119,6 +125,6 @@ describe('the shipped Windows composition (real bundle layers)', () => { [...baseOnly.layers.map(layer => layer.patches), win32!.patches], message => baseWarnings.push(message), ) - expect(baseWarnings.some(message => message.includes('ui-permission'))).toBe(true) + expect(baseWarnings).toEqual([]) }) }) diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 34691ad25b..be2c311fd1 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/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 docs/core-data-structures/sandbox.md -sandbox.md: 9e5feafe046f18dad49aeaf281793f0b8e03c240 -sandbox.zh.md: a1314d1b78eb0d46ea4c8ca5aa330ee83bf89132 +sandbox.md: af2b9043c52f35b7f38b368de1420d0e2856a963 +sandbox.zh.md: 5638e76769639525b1884756805d0e0cef1e870e diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9e5feafe04..af2b9043c5 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -8,7 +8,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv /** diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index a1314d1b78..5638e76769 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -8,7 +8,7 @@ ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外);`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index cc58dd3f8f..19f2c66290 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 9e2a6305fff592eb63f927dfcc4f32437edeb1cc -README.zh.md: 6159c23047bba4ced3345125901cc0c3ff7cd960 +README.md: de89e8c5d1fcf78fcd47ebdb98c1d1fc3bb0bd5f +README.zh.md: 45ab3d9fa3c750984d903475b3c9e027e9e95d1c diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 9e2a6305ff..de89e8c5d1 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack restores it through its profile or home `cordis.patch.yml` (disable `pwsh-local`/`tool-pwsh` and re-enable `bash-sandbox`/`tool-bash` — both executors register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows has no sandbox and no approval** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack (`sandbox`/`sandbox-policy`/`fs-sandbox` disabled, `dsh-fs-local` provides `ctx.fs`), the permission switcher leaves the roster, and the approval service is disabled — nothing on Windows asks for approval, so the model is never told approval exists. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. +- **The Windows temp grant is the real temp directory** — `workspace-write` confines writes to the workspace plus the host temp area (the same backend-defined choice the Landlock rung makes); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 6159c23047..45ab3d9fa3 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 恢复 bash 栈(禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上没有沙箱、没有 approval**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`——权限切换器离开清单,approval 服务也被禁用:Windows 上没有任何动作需要审批,模型也不会被告知审批存在。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 +- **Windows 的临时目录授权是真实 temp 目录**——`workspace-write` 把写入限制在工作区与宿主 temp 区域(与 Landlock 档位相同的后端定义选择);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index ab2dc874ce..2da84a0931 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -52,7 +52,7 @@ describe('dsh-base bundle', () => { const inserted = parsed .flatMap(patch => patch.insert ?? []) .map(row => row.id) - expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh', 'fs-local']) + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh']) // The patch no longer touches the permission/approval surface at all. expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 66ed54112f..6db6a57098 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -5,7 +5,9 @@ # stack is the SANDBOXED PowerShell executor plus the full permission # surface: sandbox/sandbox-policy enforce the file-effect policy, the # permission switcher and the approval service run exactly as on POSIX, and -# fs-sandbox fences the in-process filesystem view. Only the POSIX bash +# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting +# dsh-fs-local alongside it would double-register ctx.fs and fail the load. +# Only the POSIX bash # stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. # A Windows host that prefers the unconfined local pwsh executor or full # access overrides these rows through its profile or home cordis.patch.yml. @@ -27,6 +29,3 @@ - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' - - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' From bec3cba0844636543f38d8b26aa0075fb1332a4b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:25:44 +0800 Subject: [PATCH 15/62] test(scripts): split placed-image paths on both separators in project-doc-site.spec.ts The placer fixtures derive the basename with split('/'), which never splits a Windows absolute path (backslashes), so the two image-placement tests fail on win32 hosts. The production rewrite passes absolute paths through; the fixtures now accept both separators. --- scripts/project-doc-site.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 6770381526..82e4077829 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = absPath.split(/[\\/]/u).pop() ?? '' placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${absPath.split(/[\\/]/u).pop() ?? ''}`, })).toBe('![logo](./logo.svg#view)\n') }) From dcd5a5037420d754358d7662d4289298e198d02a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:26:26 +0800 Subject: [PATCH 16/62] docs(sandbox): satisfy the doc-sync gates for the Windows ACL packages JSDoc every sandbox-windows-acl export (the 138 verify-export-jsdoc violations); fix the sandbox-windows-acl README code block (undeclared workspaceRoot), bring its stale claims current (the provider wiring and kill-on-close are live), add the canonical Model Experience sections to both new packages, audited the backend's indirect Model Experience sentence, and regenerate docs/config-catalog.md. --- docs/config-catalog.md | 23 +++- packages/bash/pwsh-sandbox/README.i18n.yaml | 4 +- packages/bash/pwsh-sandbox/README.md | 14 ++- packages/bash/pwsh-sandbox/README.zh.md | 14 ++- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 15 ++- .../sandbox/sandbox-windows-acl/README.zh.md | 19 ++- .../sandbox/sandbox-windows-acl/src/acl.ts | 7 ++ .../sandbox/sandbox-windows-acl/src/ffi.ts | 110 +++++++++++++++--- .../sandbox/sandbox-windows-acl/src/index.ts | 8 ++ .../sandbox/sandbox-windows-acl/src/spawn.ts | 29 ++++- .../sandbox/sandbox-windows-acl/src/token.ts | 19 ++- .../sandbox-windows-acl/src/win32-abi.ts | 62 ++++++++++ .../verify-package-readme-model-experience.ts | 1 + 14 files changed, 292 insertions(+), 37 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..a5f89567f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1100,6 +1100,26 @@ export interface Config { Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +## `@deepseek-ai/dsh-pwsh-sandbox` + +Requires: `subprocess` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig +``` + +Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) + +Source: [`packages/bash/pwsh-sandbox/src/index.ts:39`](../packages/bash/pwsh-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1175,7 +1195,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2611,6 +2631,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) +- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/sdk-client/src/index.ts`](../packages/sdk/sdk-client/src/index.ts)) diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index 5de8fc8e5f..f8100bf8a0 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md -README.md: 222c61176b71042bfcb539a839507a425a1fb9e5 -README.zh.md: 02326bc36c9a7e1fe41817b9ab782e31a6d0b9b9 +README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 +README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index 222c61176b..bd506d011f 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -15,9 +15,19 @@ The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process ### Confinement works, denial surfaces as command failure -The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. +#### What the model sees -## Known Limitations +The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +#### Token effect + +No model-visible text beyond the command's stderr and the tool layer's standard denial surface. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + +## Known Limitations and Deferred Work - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index 02326bc36c..e9aa380302 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -15,9 +15,19 @@ ### 隔离生效,拒绝以命令失败呈现 -模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 +#### 模型看到什么 -## 已知限制 +受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +#### Token 影响 + +除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + +## 已知限制与后续工作 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index d1ed2c0d43..42b5579325 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 34a9e7160bae3af93da17cae254202bc1b555967 -README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab +README.md: 7896764867a0266bbd1e26210a2c7cccbcd09d62 +README.zh.md: 384bc5015167dfa8e4f650c0fcd22e32a62c0ab2 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 34a9e7160b..7896764867 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. @@ -11,6 +11,8 @@ Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted @@ -56,9 +58,16 @@ The koffi struct definitions assert their sizes against the probe at module load - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +## Model Experience + +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + ## Known Limitations and Deferred Work -- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. +- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 0ecfd146a2..384bc50151 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 @@ -11,13 +11,15 @@ ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) -await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` 本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 @@ -56,9 +58,16 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 - **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +## 模型体验 + +经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + ## 已知限制与后续工作 -- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 +- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 58020c4470..44020ae0c3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -35,6 +35,9 @@ function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: numbe * orphan SID on `path`, inheriting to subcontainers and objects. The directory * must be owned by the caller (owner implicit WRITE_DAC) — same precondition * as the POC. + * @param api - the binding table. + * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param sidPtr - the orphan write SID the ACE names. */ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { const newAclSlot = allocPtrSlot() @@ -63,6 +66,10 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): * descriptor allocation — only the descriptor may be LocalFree'd, and it must * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL * pointer itself corrupts the heap (verified the hard way). + * @param api - the binding table. + * @param path - the directory whose DACL loses the orphan-SID ACEs. + * @param sidPtr - the orphan write SID whose ACEs are removed. + * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { const ownerSlot = allocPtrSlot() diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index a2fefecdbb..6d778f7b11 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -14,9 +14,14 @@ import * as abi from './win32-abi.ts' /** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ export type NativePtr = bigint & { readonly [nativePtr]: true } -/** True for NULL pointers, however koffi returns them (null or 0n). */ +/** + * True for NULL pointers, however koffi returns them (null or 0n). + * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). + * @returns a type guard narrowing to the NULL shapes. + */ export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { return value === null || value === undefined || (value as bigint) === 0n } @@ -40,6 +45,7 @@ export interface ProcessInfoOutput { dwThreadId: number } +/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ export interface Win32Bindings { // ---- process / token handles -------------------------------------------- openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr @@ -111,6 +117,7 @@ export interface Win32Bindings { const PVOID: Ptr = koffi.pointer('void') const PPVOID: Ptr = koffi.pointer(PVOID) +/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { cb: 'uint32', lpReserved: 'str16', @@ -132,6 +139,7 @@ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { hStdError: PVOID, }) +/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { hProcess: PVOID, hThread: PVOID, @@ -146,78 +154,127 @@ if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } -/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +/** + * Allocate one pointer-sized slot (for `T **` out-parameters). + * @returns the allocated slot pointer. + */ export function allocPtrSlot(): NativePtr { const value: unknown = koffi.alloc(PVOID, 1) return value as NativePtr } -/** Allocate one uint32 slot. */ +/** + * Allocate one uint32 slot. + * @returns the allocated slot pointer. + */ export function allocUint32(): NativePtr { const value: unknown = koffi.alloc('uint32', 1) return value as NativePtr } -/** Write a uint32 value into a slot pointer. */ +/** + * Write a uint32 value into a slot pointer. + * @param slot - the slot allocated by {@link allocUint32}. + * @param value - the uint32 to encode. + */ export function encodeUint32(slot: NativePtr, value: number): void { koffi.encode(slot, 'uint32', value) } -/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +/** + * Decode the pointer stored in a pointer-sized slot (NULL becomes null). + * @param slot - the pointer-sized slot holding the out-parameter value. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtr(slot: NativePtr): NativePtr | null { const value: unknown = koffi.decode(slot, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Decode a uint32 at a slot pointer. */ +/** + * Decode a uint32 at a slot pointer. + * @param slot - the uint32 slot holding the out-parameter value. + * @returns the decoded uint32. + */ export function decodeUint32(slot: NativePtr): number { const value: unknown = koffi.decode(slot, 'uint32') return value as number } -/** Decode a UTF-16 string at a pointer. */ +/** + * Decode a UTF-16 string at a pointer. + * @param ptr - pointer to the NUL-terminated UTF-16 string. + * @returns the decoded string. + */ export function decodeStr16(ptr: NativePtr): string { const value: unknown = koffi.decode(ptr, 'str16') return value as string } -/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +/** + * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). + * @param ptr - the koffi pointer. + * @returns the pointer's numeric address. + */ export function ptrAddress(ptr: NativePtr): bigint { return koffi.address(ptr) } -/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +/** + * Allocate a raw byte block (used for SID copies and variable-length arrays). + * @param length - the block size in bytes. + * @returns the allocated block pointer. + */ export function allocBytes(length: number): NativePtr { const value: unknown = koffi.alloc('uint8', length) return value as NativePtr } -/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +/** + * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). + * @param buffer - the buffer holding the pointer value. + * @param offset - byte offset of the pointer inside the buffer. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { const value: unknown = koffi.decode(buffer, offset, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Allocate a zeroed STARTUPINFOW. */ +/** + * Allocate a zeroed STARTUPINFOW. + * @returns the allocated struct pointer. + */ export function allocStartupInfo(): NativePtr { const value: unknown = koffi.alloc(STARTUPINFOW, 1) return value as NativePtr } -/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +/** + * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). + * @param startupInfo - the allocated STARTUPINFOW to encode into. + * @param fields - the field subset to write. + */ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { koffi.encode(startupInfo, STARTUPINFOW, fields) } -/** Allocate a zeroed PROCESS_INFORMATION. */ +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns the allocated struct pointer. + */ export function allocProcessInfo(): NativePtr { const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) return value as NativePtr } -/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +/** + * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. + * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. + * @returns the decoded handle/id fields. + */ export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) return value as ProcessInfoOutput @@ -276,12 +333,20 @@ function bindings(): Win32Bindings { return cached } -/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ export function win32(): Promise { return Promise.resolve(bindings()) } -/** Turn a Win32 error code into readable text via FormatMessageW. */ +/** + * Turn a Win32 error code into readable text via FormatMessageW. + * @param api - the binding table. + * @param win32Code - the error code to format. + * @returns the formatted message text, or '' when formatting fails. + */ export function errorText(api: Win32Bindings, win32Code: number): string { const buffer = Buffer.alloc(1024) const length = api.formatMessageW( @@ -295,13 +360,24 @@ export function errorText(api: Win32Bindings, win32Code: number): string { /** * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately * after the failed call so GetLastError is not clobbered by other Win32 calls. + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. */ export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { const win32Code = api.getLastError() throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } -/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +/** + * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param win32Code - the API's returned error code. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. + */ export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 77655881ca..f597ebbc72 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -38,6 +38,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { Win32Error } from './errors.ts' +/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] @@ -51,6 +52,7 @@ export interface AclSandboxOptions { writeSid?: string } +/** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ export interface AclSandboxSpawnOptions { /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ command: string @@ -67,12 +69,14 @@ export interface AclSandboxSpawnOptions { stdio?: 'pipe' | 'inherit' } +/** A settled confined child: captured stdio and the exit code. */ export interface AclSandboxChildResult { stdout: Buffer stderr: Buffer exitCode: number } +/** A running confined child: its pid and a settlement promise. */ export interface AclSandboxChild { /** Child process id. */ pid: number @@ -98,7 +102,9 @@ function getTempPath(api: Win32Bindings): string { * failure. */ export class AclSandbox { + /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] + /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string private readonly tempDirOption: string | null | undefined private tempDirResolved: string | null | undefined @@ -199,6 +205,8 @@ export class AclSandbox { * placed in a kill-on-close job (dies with the caller). Call dispose() only * after all children have exited — revoking grants under a live child * removes its remaining write allowance. + * @param options - the program, argv/cwd, and stdio shape. + * @returns the running child. */ spawn(options: AclSandboxSpawnOptions): AclSandboxChild { const api = this.api diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 58571257a1..d9c7c70564 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -17,6 +17,8 @@ import * as abi from './win32-abi.ts' * Quote one argument per the CommandLineToArgvW parsing rules (backslash * escaping only before quotes; a trailing backslash before the closing quote * is doubled). + * @param argument - one argv entry to quote. + * @returns the quoted entry (bare when quoting is unnecessary). */ export function quoteArg(argument: string): string { if (argument === '') return '""' @@ -37,7 +39,12 @@ export function quoteArg(argument: string): string { return quoted + '"' } -/** Build the single command line CreateProcess parses from program + argv. */ +/** + * Build the single command line CreateProcess parses from program + argv. + * @param program - the executable (argv[0]). + * @param args - the remaining argv entries. + * @returns the joined, quoted command line. + */ export function buildCommandLine(program: string, args: readonly string[]): string { return [program, ...args].map(quoteArg).join(' ') } @@ -63,6 +70,7 @@ function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): v } } +/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ export interface SpawnedNative { pid: number process: NativePtr @@ -74,6 +82,10 @@ export interface SpawnedNative { * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends * are returned for draining. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles. */ export function spawnSandboxed( api: Win32Bindings, @@ -133,7 +145,12 @@ export function spawnSandboxed( } } -/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +/** + * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. + * @param api - the binding table. + * @param handle - the pipe read end to drain (closed when done). + * @returns the complete pipe contents. + */ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { const chunks: Buffer[] = [] for (;;) { @@ -167,6 +184,9 @@ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise< * the child has already exited, so this wait returns immediately. Calling it * earlier would block the event loop and starve the drains (the pipe-buffer * deadlock the POC comments warn about). + * @param api - the binding table. + * @param process - the child process handle (closed when done). + * @returns the child's exit code. */ export function waitForExit(api: Win32Bindings, process: NativePtr): number { const waitResult = api.waitForSingleObject(process, abi.INFINITE) @@ -197,6 +217,7 @@ function createKillOnCloseJob(api: Win32Bindings): NativePtr { return job } +/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ export interface SpawnedInherited { pid: number process: NativePtr @@ -217,6 +238,10 @@ export interface SpawnedInherited { * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles * ("The handle is invalid", verified the hard way). The child starts * suspended so it can be assigned to a kill-on-close job before it runs. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles and job. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index e1c0f2dc2a..569c428551 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -16,6 +16,8 @@ import * as abi from './win32-abi.ts' * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token * handle is obtained through a real OpenProcess handle because the * GetCurrentProcess() pseudo-handle is not addressable through koffi). + * @param api - the binding table. + * @returns the opened token handle. */ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) @@ -42,6 +44,9 @@ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and * other per-logon objects; the POC extracts it the same way. + * @param api - the binding table. + * @param token - the token whose groups are scanned. + * @returns a copied logon SID (thrown when the token carries none). */ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { const neededSlot = allocUint32() @@ -70,7 +75,12 @@ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) } -/** Create one well-known SID (68-byte buffer) and assert its validity. */ +/** + * Create one well-known SID (68-byte buffer) and assert its validity. + * @param api - the binding table. + * @param type - the WELL_KNOWN_SID_TYPE to create. + * @returns the created SID pointer. + */ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) const sizeSlot = allocUint32() @@ -91,6 +101,7 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } +/** The well-known SIDs packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr authUser: NativePtr @@ -105,6 +116,12 @@ export interface RestrictingSidSet { * write SID that forms the write allowlist. S-1-2-1 (console logon) is * intentionally absent: see win32-abi.ts for the verified failure modes. * FAILS CLOSED: any failure throws — never spawn unrestricted. + * @param api - the binding table. + * @param currentToken - the process token to restrict. + * @param logonSid - the copied logon session SID. + * @param writeSid - the orphan SID forming the write allowlist. + * @param known - the well-known SIDs entering the restricting list. + * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 97e5322bbf..7696605c4e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -26,101 +26,161 @@ // ---- winnt.h --------------------------------------------------------------- // TOKEN_* access rights (winnt.h lines ~3928) +/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ export const TOKEN_DUPLICATE = 0x0002 +/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ export const TOKEN_QUERY = 0x0008 +/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ export const TOKEN_ADJUST_DEFAULT = 0x0080 // SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +/** + * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with + * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). + */ export const SE_GROUP_LOGON_ID = 0xC0000000 // Generic file access (winnt.h lines ~5893-5913): // FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES // | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ export const FILE_GENERIC_WRITE = 0x00120116 // What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as // "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +/** + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL — the write-access mask + * the orphan-SID ACEs grant (displays as "Write" in Explorer/icacls). + */ export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 // CreateRestrictedToken flags (winnt.h lines ~4284) +/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ export const DISABLE_MAX_PRIVILEGE = 0x1 +/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ export const LUA_TOKEN = 0x4 +/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +/** WinWorldSid: S-1-1-0 (Everyone). */ export const WinWorldSid = 1 +/** WinLocalSid: S-1-2-0 (LOCAL); CreateWellKnownSid(WinLocalSid) fails with ERROR_INVALID_PARAMETER on Windows 11 build 26200. */ export const WinLocalSid = 2 +/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ export const WinInteractiveSid = 11 +/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ export const TokenGroups = 2 // SECURITY_INFORMATION (winnt.h line ~4293) +/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ export const DACL_SECURITY_INFORMATION = 0x00000004 // PROCESS access rights (winnt.h lines ~4364) +/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ export const PROCESS_QUERY_INFORMATION = 0x0400 // ---- accctrl.h ------------------------------------------------------------- // SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ export const SE_FILE_OBJECT = 1 // TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ export const TRUSTEE_IS_UNKNOWN = 0 +/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ export const TRUSTEE_IS_SID = 0 +/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ export const NO_MULTIPLE_TRUSTEE = 0 // ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ export const GRANT_ACCESS = 1 +/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ export const REVOKE_ACCESS = 4 // grfInheritance (accctrl.h lines ~137-142) +/** + * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its + * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). + */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE // ---- winbase.h ------------------------------------------------------------- +/** + * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* + * handles, required because Node clears stdio inheritability at startup. + */ export const STARTF_USESTDHANDLES = 0x00000100 +/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ export const HANDLE_FLAG_INHERIT = 0x1 +/** INFINITE: never-timeout wait value. */ export const INFINITE = 0xFFFFFFFF +/** MAX_PATH: legacy path length bound. */ export const MAX_PATH = 260 // winbase.h line ~410: the confined child starts suspended so the runner can // assign it to the kill-on-close job before any of its code runs. +/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ export const CREATE_SUSPENDED = 0x4 // winbase.h lines ~497-499: GetStdHandle selectors. +/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ export const STD_INPUT_HANDLE = -10 +/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ export const STD_OUTPUT_HANDLE = -11 +/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ export const STD_ERROR_HANDLE = -12 // FormatMessageW flags (winbase.h lines ~1446-1469) +/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 // ---- error codes ----------------------------------------------------------- +/** ERROR_SUCCESS: the operation succeeded. */ export const ERROR_SUCCESS = 0 +/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ export const ERROR_INSUFFICIENT_BUFFER = 122 +/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ export const ERROR_BROKEN_PIPE = 109 +/** ERROR_NO_DATA: the pipe is being closed. */ export const ERROR_NO_DATA = 232 // ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last // job handle closes — the orphan-child backstop for the runner design. +/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 // JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ export const JobObjectExtendedLimitInformation = 9 // sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 // LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION // (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), // verified by abi-probe. +/** + * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION + * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + + * PerJobUserTimeLimit@8), verified by abi-probe. + */ export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 // ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- +/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ export const SECURITY_MAX_SID_SIZE = 68 /** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ export const SID_AND_ATTRIBUTES_SIZE = 16 @@ -132,5 +192,7 @@ export const EXPLICIT_ACCESS_W_SIZE = 48 export const TRUSTEE_W_OFFSET = 16 /** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +/** sizeof(STARTUPINFOW), verified by abi-probe. */ export const STARTUPINFOW_SIZE = 104 +/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..663501c763 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, From 264420127ad7ad91c68feda9ca8452ed12bf3680 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:32:54 +0800 Subject: [PATCH 17/62] =?UTF-8?q?docs(sandbox):=20record=20the=20Windows?= =?UTF-8?q?=20rung=20design=20choice=20=E2=80=94=20raw=20ACL=20restricted?= =?UTF-8?q?=20tokens=20over=20mxc=20and=20AppContainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the implemented Agent Note owning the win32 rung decision: mxc rejected (OS floor 24H2, BaseContainer tier only on 25H2+ with the OS feature enabled, and arbitrary-path reads demand wholesale host DACL writes), AppContainer rejected (no ambient read access — arbitrary-path reads unsupported), landstrip already rejected and AppContainer-shaped. Link the note from the backend README pair and restate the choice in the PR description. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 6 ++++ ...08-windows-acl-restricted-token-sandbox.md | 35 +++++++++++++++++++ ...windows-acl-restricted-token-sandbox.zh.md | 35 +++++++++++++++++++ .../sandbox-windows-acl/README.i18n.yaml | 4 +-- .../sandbox/sandbox-windows-acl/README.md | 2 ++ .../sandbox/sandbox-windows-acl/README.zh.md | 2 ++ 6 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml new file mode 100644 index 0000000000..1f9871f6d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +2026-08-08-windows-acl-restricted-token-sandbox.md: 2ade3233d51d665cc919080fc898a60e5bea7717 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 701bf2a8f84df7a5bb6666b7dd0996e56f4c3686 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md new file mode 100644 index 0000000000..2ade3233d5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md) + +## Problem + +The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading. + +## Decision + +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. + +## Alternatives considered + +### Why not mxc (Microsoft xContainer)? + +Two disqualifiers. First, the OS floor is too new: the [mxc OS-version policy](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, `Experimental_CreateProcessInSandbox`) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need. + +### Why not AppContainer? + +An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only. + +### Why not landstrip? + +The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem. + +## Consequences + +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. + +## Related + +The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md new file mode 100644 index 0000000000..701bf2a8f8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +[English](2026-08-08-windows-acl-restricted-token-sandbox.md) | 中文 + +## Problem + +[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。 + +## Decision + +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 + +## Alternatives considered + +### 为什么不选 mxc(Microsoft xContainer)? + +两个否决理由。其一,OS 版本要求太新:[mxc 的 OS 版本支持文档](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md)把产品下限设在 Windows 11 24H2(build 26100),而 BaseContainer 档(T1,`Experimental_CreateProcessInSandbox`)只在 25H2+(build 26600+)且启用 OS feature 时存在——在 25H2 及以下的所有受支持版本上,文件系统策略都会回退到 T3,即 AppContainer 加宿主侧 DACL ACE 改造。其二,在任一档下支持任意路径读都意味着要为子进程可读的每个路径写 ACL 授予读权限:模型要读整个工作区和任意文件,就需要全盘改写宿主 DACL——对只做写限制的需求而言,这是不必要的驻留副作用与代价。 + +### 为什么不选 AppContainer? + +AppContainer 令牌没有环境读访问:每个可读路径都必须预先通过 capability 或显式 ACE 授予,因此任意路径读——harness 的读模型——在不做同样的全盘授予时无法支持。受限令牌完全不需要读授予:它只对写访问做交集。 + +### 为什么不选 landstrip? + +[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。 + +## Consequences + +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 + +## Related + +[pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 42b5579325..b23dc56468 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 7896764867a0266bbd1e26210a2c7cccbcd09d62 -README.zh.md: 384bc5015167dfa8e4f650c0fcd22e32a62c0ab2 +README.md: a3b12b6298cc30f490adf7aa8d2cc1916fd48827 +README.zh.md: c3ff553dca4ca3866b6dd0d20381cecd906eba42 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 7896764867..a3b12b6298 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -6,6 +6,8 @@ Windows write-restriction sandbox backend for the [harness sandbox seam](../sand Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). + ## Usage ```ts diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 384bc50151..c3ff553dca 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -6,6 +6,8 @@ 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 + ## 用法 ```ts From aa790346e45f31a680400022f3f59ecbab84b7a5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:42:47 +0800 Subject: [PATCH 18/62] docs(sandbox): explain why the token intersection needs no new identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the mechanism section to the Windows rung Agent Note: identity routes (restricted-user, AppContainer) default-deny everything from a fresh SID and pay per-path ACE writes for every read, while the WRITE_RESTRICTED token keeps the caller's SID and double-checks write-class access only — reads pass on the normal check alone. Covers DISABLE_MAX_PRIVILEGE/LUA_TOKEN as the token-side limited-user synthesis and why SidsToDisable read restriction is unused. --- .../2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- .../2026-08-08-windows-acl-restricted-token-sandbox.md | 4 ++++ .../2026-08-08-windows-acl-restricted-token-sandbox.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 1f9871f6d7..c75002e390 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 2ade3233d51d665cc919080fc898a60e5bea7717 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 701bf2a8f84df7a5bb6666b7dd0996e56f4c3686 +2026-08-08-windows-acl-restricted-token-sandbox.md: 167f4144a3cf6a80d00261ddb09bf3db0e024d63 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: c49237072c8b831ede927b2c4704676837029397 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 2ade3233d5..167f4144a3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -12,6 +12,10 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +## How the restriction works (why no new identity) + +The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. + ## Alternatives considered ### Why not mxc (Microsoft xContainer)? diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 701bf2a8f8..c49237072c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -12,6 +12,10 @@ Status: implemented 直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +## How the restriction works (why no new identity) + +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 + ## Alternatives considered ### 为什么不选 mxc(Microsoft xContainer)? From d8acd2b65645ff02e6c5dd229d039ca5b6ead751 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 11:46:05 +0800 Subject: [PATCH 19/62] fix(ci): pass the pwsh-less self-hosted Linux runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted Linux runners ship no pwsh, and the pwshAvailable probes used spawnSync('where.exe'), which reports a missing binary as status null instead of throwing — the suites never skipped and failed with spawn pwsh ENOENT. Probe with resolvePwshPath() status instead, the same gate the coverage exemption uses. Exempt pwsh-sandbox src from coverage on pwsh-less hosts (its remaining helpers branch and the invariant companion ride the executor suites' real pwsh runs); pwsh-ful hosts keep the full 100% bar. Cover the windows-acl probe case and the runner-entry resolution in sandbox-local on Linux (chain-seam tests plus a windowsAclRunnerEntry seam) — the package's POSIX-only suites are Linux's only chance to cover the new lines. Static gate fixes: declare dsh-pwsh-sandbox in the base bundle, register the runner files entry in constraints, knip entries for the e2e suite and where.exe, regenerate the module graph. Verified in WSL (no-pwsh Linux): pwsh-sandbox 5 pass/13 skip with the exemption active, sandbox-local coverage 100%. --- docs/module-graph.md | 10 ++++ knip.json | 13 +++- packages/bash/pwsh-sandbox/package.json | 1 - packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 8 +-- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 11 ++-- packages/bundle/base/package.json | 2 +- packages/sandbox/sandbox-local/src/index.ts | 4 +- .../sandbox/sandbox-local/tests/local.spec.ts | 60 +++++++++++++++++++ .../sandbox/sandbox-windows-acl/package.json | 2 +- pnpm-lock.yaml | 7 +-- scripts/check-workspace-constraints.ts | 3 + vitest.config.ts | 22 ++++--- 12 files changed, 113 insertions(+), 30 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..3657523e5b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -42,6 +42,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -236,6 +237,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_sdk["packages/sdk"] pkg_helper["helper"] @@ -309,6 +311,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_typert_generator --> pkg_invariants @@ -632,6 +635,11 @@ flowchart TD pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_invariants @@ -1152,6 +1160,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | @@ -1240,6 +1249,7 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/knip.json b/knip.json index 7a3922c8fb..e6ed5506e3 100644 --- a/knip.json +++ b/knip.json @@ -7,7 +7,8 @@ "bwrap", "python3", "sandbox-exec", - "taskkill" + "taskkill", + "where.exe" ], "ignoreWorkspaces": [ "vendor/*", @@ -217,6 +218,16 @@ "tests/**/*.ts" ] }, + "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/context/time-context": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 47f51b2ac1..6f2a87fc58 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -39,7 +39,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index a5909f0544..bb6f4f25e4 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -22,12 +23,7 @@ import { SandboxPwshExecutor } from '../src/index.ts' const isWin32 = process.platform === 'win32' function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 1c8e5e1bd0..9e4aa04546 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -14,18 +14,17 @@ import { afterAll, describe, expect, it } from 'vitest' import { Context, Service } from 'cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { SandboxPwshExecutor } from '../src/index.ts' import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' +// The same probe pwsh-local's suites and the vitest coverage exemption use: +// spawnSync never throws on a missing binary (it reports status null), and +// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth. function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 593d5d3f98..f7ee47e343 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 22182095d5..0b562616ea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -112,6 +112,8 @@ export interface SandboxInternals { seatbeltExec?: string /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ windowsAclRunnerArgs?: string[] + /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */ + windowsAclRunnerEntry?: string /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ probeWindowsAcl?: () => boolean } @@ -368,7 +370,7 @@ export class LocalSandboxProvider extends SandboxProvider { private windowsAclRunnerInvocation(): string[] { const override = this.internals.windowsAclRunnerArgs if (override !== undefined) return override - const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) if (existsSync(builtEntry)) return [process.execPath, builtEntry] const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) return [process.execPath, '--import', 'tsx/esm', sourceEntry] diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 5e12b37ad2..3c764a8bae 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -365,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) }) + +describe('the windows-acl probe (runner invocation contract)', () => { + // The product chain reaches windows-acl only unprobed (win32's sole + // candidate), so the probe case and the runner-entry resolution are pinned + // through the chain seam, mirroring the seatbelt default-probe contract. + it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl, + probeBwrap: () => false, + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + }) + const confined = sandbox.confine(['true'], RO) + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) + + it('reads a failing probe as unusable and walks to the next rung', async () => { + const probeWindowsAcl = vi.fn(() => false) + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + }) + + it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { + // The default probe spawns the exact runner argv confine would use — the + // runner source through tsx on a lib-less checkout. The windows-acl + // runner cannot init off win32, so the probe reads unusable and the walk + // falls through to the injected bwrap verdict on every host. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }, 30_000) + + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { + // windowsAclRunnerInvocation always yields [node, ...] in product; an + // override returning [] exercises the default probe's empty-argv guard. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }) + + it('prefers the built lib/runner.js entry when the resolved file exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + const builtEntry = join(dir, 'runner.js') + writeFileSync(builtEntry, '') + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: builtEntry, + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3e2afd8fb3..3f4d6cc065 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -24,8 +24,8 @@ }, "files": [ "lib/index.js", - "lib/runner.js", "lib/invariant.js", + "lib/runner.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b55e1a2bcf..391c0947a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,9 +773,6 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-sandbox-windows-acl': - specifier: workspace:^ - version: link:../../sandbox/sandbox-windows-acl '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -973,9 +970,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ - version: link:../../bash/pwsh-local + version: link:../../bash/pwsh-sandbox '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f0cea80064..37ac29a1a5 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -110,6 +110,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], + // The argv-prefix runner entry ships beside the lib as its own bundle; + // sandbox-local resolves it through the package's ./runner export. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/vitest.config.ts b/vitest.config.ts index 4aaf7258e0..dc0848ff0d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,16 +57,22 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] -// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites -// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file -// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts -// green while CI runners ship pwsh and still enforce the full bar. The probe -// runs the suites' own resolution (the dependency-free resolve.ts module), -// so the exemption is active exactly when the suites skip — a mismatched -// narrower probe could exempt the file on hosts whose suites actually run. +// Mirrors windowsCoverageExclusions: pwsh-local's and pwsh-sandbox's +// run/start/lifecycle suites self-skip without a real pwsh (executor.spec.ts +// hasPwsh, sandbox.spec.ts pwshAvailable). pwsh-local keeps the bar on its +// pwsh-independent modules and exempts only the executor file; pwsh-sandbox's +// remaining helpers branch (classifyDenial) and its invariant companion both +// ride the executor suites' real pwsh runs, so on a pwsh-less host (the +// self-hosted Linux runners ship no pwsh) every source file would sit below +// the per-file bar — exempt the whole package src there, mirroring +// windowsOnlyCoverageExclusions. The probe runs the suites' own resolution +// (resolvePwshPath), so the exemption is active exactly when the suites skip. const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 ? [] - : ['packages/bash/pwsh-local/src/index.ts'] + : [ + 'packages/bash/pwsh-local/src/index.ts', + 'packages/bash/pwsh-sandbox/src/**/*.ts', + ] const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', From ae7d16815da044f835662b727dcfda6c99c2aa6c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 11:51:53 +0800 Subject: [PATCH 20/62] chore: retrigger CI (no workflow run was created for the previous push) From e660ec8cf586e3d5300dbf08cf6e1ca3411ccb20 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 12:20:49 +0800 Subject: [PATCH 21/62] fix(sandbox): pack the windows-acl rung in the publish-path rehearsal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox-local now depends on @deepseek-ai/dsh-sandbox-windows-acl, so the packed-install e2e must carry its tarball in the workspace closure — npm cannot resolve the private package from the registry. koffi and the shared peers (dsh-invariants, cordis) already resolve from the registry or the existing closure. --- packages/sandbox/sandbox-local/tests/packed-install.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index a032e1add7..408a4061a9 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -23,6 +23,10 @@ const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) /** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', + // sandbox-local's win32 chain rung is a runtime dependency: a packed + // consumer resolves it like any other @deepseek-ai peer (koffi arrives + // from the registry). + 'packages/sandbox/sandbox-windows-acl', 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', From 83f288993fea768632f5b3aa735ebf9ff00d55f2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 12:35:27 +0800 Subject: [PATCH 22/62] fix(sandbox): mark the pwsh-sandbox executor's deliberate twin mirror for the clone gate The duplication gate flags the call-for-call mirror of bash-sandbox's executor (the pwsh-tool-and-executor decision): wrap the class body in jscpd ignore markers, the same convention the invariant companions use. --- packages/bash/pwsh-sandbox/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index c88719f889..38e44a431a 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -45,6 +45,7 @@ export type Config = LocalConfig * calls fall back to deployment policy. `result.sandbox` reports the mode and * enforcement actually used. */ +/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */ export class SandboxPwshExecutor extends PwshLocalExecutor { static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] @@ -180,5 +181,6 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { return this.ctx.sandbox.confine(this.argv(spec), policy) } } +/* jscpd:ignore-end */ export default SandboxPwshExecutor From 2ecfe383c795752c37ea6c728806e81c6508ceb3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 13:44:05 +0800 Subject: [PATCH 23/62] fix(cli): resolve the pwsh-sandbox layer row from the cold-start module closure The launcher's healProfilesModuleFallback BFS-links the apps/cli dependency closure into the profile's node_modules (the pwsh-local precedent): without dsh-pwsh-sandbox in apps/cli dependencies, a fresh Windows host cannot resolve the inserted row. Assert the closure reaches both inserted packages in the real-layers composition; rewrap the windows-shell layer comment. --- apps/cli/package.json | 1 + apps/cli/src/windows-shell.ts | 2 +- apps/cli/tests/windows-shell.spec.ts | 9 ++++++++- pnpm-lock.yaml | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 5e4f189517..4673e64bc4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 6bdca7b741..0058d89d38 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -2,7 +2,7 @@ * The Windows shell platform layer: on win32 hosts the shipped profile * compositions swap the POSIX-only bash stack for the sandbox-confined * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + - * `@deepseek-ai/dsh-tool-pwsh`), matching the Windows-pwsh-default roadmap. The layer is the base bundle's + * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's * `windows.cordis.patch.yml`, injected by the launcher between the bundle * layers and the user layers so a user patch can still override it — the * only override channel is composition config, like every other roster diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 15f5e0521b..cc77619f8c 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -97,6 +97,13 @@ describe('the shipped Windows composition (real bundle layers)', () => { for (const id of ['pwsh-sandbox', 'tool-pwsh']) { expect(byId.has(id), `inserted row ${id}`).toBe(true) } + // The launcher's cold-start module fallback BFS-links the apps/cli + // dependency closure into the profile's node_modules (the pwsh-local + // precedent), so every inserted bare plugin must resolve from there. + const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record } + for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) { + expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined() + } // The patch touches only base-owned rows plus inserts, so the full web // profile composes without any no-match warning. expect(warnings).toEqual([]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b390c886a0..b06386f144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local + '@deepseek-ai/dsh-pwsh-sandbox': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-sandbox '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference From fa591b5ebd73e32878e924c717fcb238746c54c6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 13:44:10 +0800 Subject: [PATCH 24/62] docs(sandbox): record the DACL churn limitation and the win32 snapshot substitution Known Limitations gains the per-command DACL mutation entry (inheritance is lazy, not a per-file walk; per-session grant reuse is deferred work); the design note gains a Testing section stating why the win32-only roster flip cannot ride macOS/Linux snapshot fixtures and naming the substitute evidence. --- .../2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- .../2026-08-08-windows-acl-restricted-token-sandbox.md | 4 ++++ .../2026-08-08-windows-acl-restricted-token-sandbox.zh.md | 4 ++++ packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 1 + packages/sandbox/sandbox-windows-acl/README.zh.md | 1 + 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c75002e390..9bb02e92b6 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 167f4144a3cf6a80d00261ddb09bf3db0e024d63 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: c49237072c8b831ede927b2c4704676837029397 +2026-08-08-windows-acl-restricted-token-sandbox.md: db793ab31a853a6627b2e30f4dd3f71542e3836d +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a3d69a2dd94de2fa4ef83aa16ee297c1939b767d diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 167f4144a3..db793ab31a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -34,6 +34,10 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. +## Testing + +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. + ## Related The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index c49237072c..a3d69a2dd9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -34,6 +34,10 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 +## Testing + +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。 + ## Related [pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index b23dc56468..ad25c7e95f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: a3b12b6298cc30f490adf7aa8d2cc1916fd48827 -README.zh.md: c3ff553dca4ca3866b6dd0d20381cecd906eba42 +README.md: d4f99c44f0542e9ed6e7d4605730ad0f0311a22c +README.zh.md: 9b51ccf7b29aefc3dea9a1c97bad75c2f41f5f98 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index a3b12b6298..d4f99c44f0 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -72,4 +72,5 @@ None directly; the denial surface belongs to the tool layer. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Each confined command mutates two directory DACLs** — a grant on entry and a revoke on exit, on the workspace root and the temp root: a handful of Win32 calls per command (inheritance is evaluated lazily per access, not a per-file walk). The runner pays this per command; reusing one grant per session is deferred work if the churn ever matters. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index c3ff553dca..9b51ccf7b2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -72,4 +72,5 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **每次受限命令都会改动两个目录的 DACL** —— 进入时授权、退出时撤销,分别作用在工作区根与临时根:每次命令若干次 Win32 调用(继承按访问惰性求值,不是逐文件遍历)。runner 按命令付这笔开销;按会话复用一次授权留作后续工作,待开销真的成为问题再实现。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 From 91d3ed6c5acf7e7af8f3c300a5ca1f5a73c64526 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 14:32:01 +0800 Subject: [PATCH 25/62] =?UTF-8?q?fix(sandbox):=20address=20the=20ACL=20bac?= =?UTF-8?q?kend=20review=20findings=20=E2=80=94=20quoting,=20DACL=20merge+?= =?UTF-8?q?lock,=20failure=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quoteArg doubles end-of-string backslashes (real CommandLineToArgvW round-trip test; first-token exemption noted); grantWrite merges into the current DACL instead of replacing it, and both grant/revoke hold a LockFileEx per-path lock under GetTempPathW/dsh-acl-locks (koffi crashes on NULL lpOverlapped — a zeroed OVERLAPPED is used); GRANT_MASK gains DELETE|FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER) though the win32 26200 second check only constrains the WRITE bit; failure paths close all handles (CreateProcessAsUserW pipe set, ResumeThread thread/process/job) with stub-api tests; getTempPathW refuses undersized buffers; drainPipe backs off; runner.spec pwshAvailable uses the resolvePwshPath probe; NTSTATUS exit codes mirror bit-exact (verified end-to-end); WinLocalSid JSDoc re-attributed to WinLocalLogonSid. All gates green: 42 passed/2 skipped, typecheck, oxlint, 0 clones, constraints, knip. --- .../sandbox/sandbox-windows-acl/package.json | 1 + .../sandbox/sandbox-windows-acl/src/acl.ts | 206 +++++++++++---- .../sandbox/sandbox-windows-acl/src/ffi.ts | 56 +++++ .../sandbox/sandbox-windows-acl/src/index.ts | 9 +- .../sandbox/sandbox-windows-acl/src/runner.ts | 9 + .../sandbox/sandbox-windows-acl/src/spawn.ts | 46 +++- .../sandbox-windows-acl/src/win32-abi.ts | 64 ++++- .../sandbox-windows-acl/tests/acl.spec.ts | 234 ++++++++++++++++++ .../tests/failure-paths.spec.ts | 112 +++++++++ .../sandbox-windows-acl/tests/quote.spec.ts | 88 +++++++ .../sandbox-windows-acl/tests/runner.spec.ts | 37 ++- .../sandbox-windows-acl/verify/abi-probe.cpp | 23 +- pnpm-lock.yaml | 3 + 13 files changed, 805 insertions(+), 83 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3f4d6cc065..2f13b71296 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 44020ae0c3..7700474d5f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -4,10 +4,19 @@ * the failure handling the POC lacks). Every API call is checked and every * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. + * + * Concurrency: grants are read-merge-write against the directory's CURRENT + * DACL, and the whole get-merge-set sequence runs under a per-path exclusive + * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances + * cannot clobber each other's ACEs. * @module @deepseek-ai/dsh-sandbox-windows-acl/acl */ -import { allocPtrSlot, decodePtr, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import { createHash } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { allocOverlapped, allocPtrSlot, decodePtr, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' @@ -18,7 +27,7 @@ import * as abi from './win32-abi.ts' * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which * removes every ACE for the trustee. */ -function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { +export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) entry.writeUInt32LE(permissions, 0) // grfAccessPermissions entry.writeUInt32LE(mode, 4) // grfAccessMode @@ -31,47 +40,82 @@ function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: numbe } /** - * Grant `FILE_GENERIC_WRITE & ~READ_CONTROL` (displays as "Write") to the - * orphan SID on `path`, inheriting to subcontainers and objects. The directory - * must be owned by the caller (owner implicit WRITE_DAC) — same precondition - * as the POC. + * One lock file per protected path: `\dsh-acl-locks\.lock`. The lock root derives from + * GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing + * maps Windows's case-insensitive path spellings onto one lock. * @param api - the binding table. - * @param path - the directory whose DACL gains the grant (the workspace or temp root). - * @param sidPtr - the orphan write SID the ACE names. + * @param path - the protected directory (absolute). + * @returns the lock file path for that directory. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { - const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), null, newAclSlot) - if (mergeResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', mergeResult, path) - const newAcl = decodePtr(newAclSlot) - if (newAcl === null) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `null ACL for ${path}`) - - const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, - null, null, newAcl, null, - ) - // Free the LocalAlloc'd ACL before any throw; capture both outcomes first. - const freed = api.localFree(newAcl) - if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, path) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path})`) +export function lockFilePath(api: Win32Bindings, path: string): string { + const digest = createHash('sha256').update(path.toLowerCase()).digest('hex').slice(0, 16) + return join(getTempPath(api), 'dsh-acl-locks', `${digest}.lock`) } /** - * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved). Returns whether an ACE removal was - * attempted (false when the directory carries no DACL at all). - * - * Allocation contract (the POC's RevokeAccess, minus its missing checks): - * GetNamedSecurityInfoW returns the DACL pointer INSIDE the security - * descriptor allocation — only the descriptor may be LocalFree'd, and it must - * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL - * pointer itself corrupts the heap (verified the hard way). + * Run `action` holding the per-path exclusive lock: CreateFileW + * (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file + * could be removed and recreated under the holder, letting two processes + * hold "the same" lock), then a one-byte LockFileEx + * (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the + * synchronous handle — see allocOverlapped for why not NULL), then + * UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures + * throw like every other Win32 call in this package; an `action` failure + * still unlocks (best-effort) and rethrows the original error. * @param api - the binding table. - * @param path - the directory whose DACL loses the orphan-SID ACEs. - * @param sidPtr - the orphan write SID whose ACEs are removed. - * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). + * @param path - the protected directory (absolute). + * @param action - the get-merge-set sequence to serialize. + * @returns the action's result. */ -export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { +export function withPathLock(api: Win32Bindings, path: string, action: () => T): T { + const lockPath = lockFilePath(api, path) + mkdirSync(dirname(lockPath), { recursive: true }) + const handle = api.createFileW( + lockPath, + abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, + null, abi.OPEN_ALWAYS, 0, null, + ) + if (isInvalidHandle(handle)) throwLastError(api, 'CreateFileW', lockPath) + const overlapped = allocOverlapped() // stays zeroed: offset 0, hEvent NULL + if (api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the lock-failure path + throwWin32(api, 'LockFileEx', win32Code, lockPath) + } + + let result: T + try { + result = action() + } catch (error) { + // Best-effort release on the action-failure path: cleanup failures must + // not mask the action's error. + api.unlockFileEx(handle, 0, 1, 0, overlapped) + api.closeHandle(handle) + throw error + } + if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the unlock-failure path + throwWin32(api, 'UnlockFileEx', win32Code, lockPath) + } + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', `lock file ${lockPath}`) + return result +} + +/** + * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Allocation contract (the POC's RevokeAccess, minus its missing checks): the + * returned ACL pointer sits INSIDE the security descriptor allocation — only + * the descriptor may be LocalFree'd, and it must not be freed before + * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself + * corrupts the heap (verified the hard way). + * @param api - the binding table. + * @param path - the directory whose DACL is read. + * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + */ +function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { const ownerSlot = allocPtrSlot() const groupSlot = allocPtrSlot() const daclSlot = allocPtrSlot() @@ -82,27 +126,39 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, ) if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) - const oldAcl = decodePtr(daclSlot) - const descriptor = decodePtr(descriptorSlot) - - if (oldAcl === null) { - if (descriptor !== null) { - const freed = api.localFree(descriptor) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) - } - return false - } + return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } +} +/** + * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` + * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), + * free the descriptor before applying the merged ACL, apply it, then free the + * merged ACL — checking every call and reporting with the caller's label. + * @param api - the binding table. + * @param path - the directory the DACL edit applies to. + * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). + * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param descriptor - the descriptor allocation owning `oldAcl`. + * @param label - the caller's name for error details. + */ +function mergeAndApply( + api: Win32Bindings, + path: string, + entry: Buffer, + oldAcl: NativePtr | null, + descriptor: NativePtr | null, + label: string, +): void { const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, newAclSlot) + const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too - throwWin32(api, 'SetEntriesInAclW', mergeResult, `revokeWrite(${path})`) + throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) } const newAcl = decodePtr(newAclSlot) if (newAcl === null) { if (descriptor !== null) api.localFree(descriptor) - throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `revokeWrite(${path}): null new ACL`) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) } // The descriptor block (oldAcl included) is dead after the merge — free it @@ -113,8 +169,52 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) null, null, newAcl, null, ) const freedNew = api.localFree(newAcl) - if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `revokeWrite(${path})`) - if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) - if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) new ACL`) - return true + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) +} + +/** + * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID + * on `path`, inheriting to subcontainers and objects. Read-merge-write: the + * new ACE merges into the directory's CURRENT explicit DACL (same shape as + * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the + * per-path lock. The directory must be owned by the caller (owner implicit + * WRITE_DAC) — same precondition as the POC. + * @param api - the binding table. + * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param sidPtr - the orphan write SID the ACE names. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + }) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Runs under the per-path lock (the whole get-merge-set sequence); the + * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * @param api - the binding table. + * @param path - the directory whose DACL loses the orphan-SID ACEs. + * @param sidPtr - the orphan write SID whose ACEs are removed. + * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + return withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + return true + }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 6d778f7b11..1d9665a8fc 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -26,6 +26,17 @@ export function isNullPtr(value: NativePtr | null | undefined): value is null | return value === null || value === undefined || (value as bigint) === 0n } +/** + * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which + * koffi hands back as the unsigned 64-bit all-ones pointer). + * @param handle - the handle CreateFileW returned. + * @returns whether the handle signals failure. + */ +export function isInvalidHandle(handle: NativePtr | null | undefined): boolean { + if (isNullPtr(handle)) return true + return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n +} + type Ptr = ReturnType /** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ @@ -86,6 +97,12 @@ export interface Win32Bindings { ): number // ---- environment / io ---------------------------------------------------- getTempPathW(length: number, buffer: Buffer): number + createFileW( + fileName: string, desiredAccess: number, shareMode: number, attributes: null, + creationDisposition: number, flagsAndAttributes: number, templateFile: null, + ): NativePtr + lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number + unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( @@ -231,6 +248,18 @@ export function allocBytes(length: number): NativePtr { return value as NativePtr } +/** + * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8, + * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this + * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a + * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent + * (the byte range locks from offset 0, hEvent stays NULL). + * @returns the zeroed block pointer. + */ +export function allocOverlapped(): NativePtr { + return allocBytes(32) +} + /** * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). * @param buffer - the buffer holding the pointer value. @@ -313,6 +342,14 @@ function bindings(): Win32Bindings { setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + // fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD, + // LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE). + createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]), + // fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD, + // DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD, + // LPOVERLAPPED). lpOverlapped is NULL for synchronous locking. + lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]), + unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ @@ -357,6 +394,25 @@ export function errorText(api: Win32Bindings, win32Code: number): string { return buffer.subarray(0, length * 2).toString('utf16le').trim() } +/** + * Read the process temp directory via GetTempPathW (fileapi.h line ~188). + * Defensive against an overlong system temp path: GetTempPathW reports the + * REQUIRED length (including NUL) without writing the buffer when it is too + * small, so a reported length beyond the buffer's capacity means the buffer + * was never filled and must not be decoded. + * @param api - the binding table. + * @returns the NUL-terminated temp path decoded as a string. + */ +export function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + if (length > buffer.length / 2) { + throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`) + } + return buffer.subarray(0, length * 2).toString('utf16le') +} + /** * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately * after the failed call so GetLastError is not clobbered by other Win32 calls. diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index f597ebbc72..e312a40e09 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -29,7 +29,7 @@ import { resolve } from 'node:path' import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' -import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' +import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' @@ -88,13 +88,6 @@ function randomWriteSid(): string { return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` } -function getTempPath(api: Win32Bindings): string { - const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) - const length = api.getTempPathW(buffer.length / 2, buffer) - if (length === 0) throwLastError(api, 'GetTempPathW') - return buffer.subarray(0, length * 2).toString('utf16le') -} - /** * One write-restricted sandbox instance: token + orphan-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes whatever was granted diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 02fd416f7f..578a615f84 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -124,6 +124,15 @@ async function main(): Promise { main().then( (exitCode) => { + // Exit-code mirroring is full-width on Windows, verified empirically on + // this machine (Windows 11 build 26200, Node 24): a child that exits + // with the NTSTATUS 0xC0000005 (STATUS_ACCESS_VIOLATION) is read back + // by GetExitCodeProcess as the uint32 3221225477, and after + // process.exitCode = 3221225477 the parent observes exactly + // 3221225477 (spawnSync status). PowerShell's $LASTEXITCODE and cmd + // print the signed view (-1073741819), but no truncation or masking + // happens anywhere in the chain — the mirror contract holds for the + // full 32-bit range, so no re-mapping is needed. process.exitCode = exitCode }, (error: unknown) => { diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index d9c7c70564..9c0436f29f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -14,9 +14,12 @@ import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' /** - * Quote one argument per the CommandLineToArgvW parsing rules (backslash - * escaping only before quotes; a trailing backslash before the closing quote - * is doubled). + * Quote one argument per the CommandLineToArgvW parsing rules: backslashes + * are doubled only before a quote character — including the closing quote + * this function appends, so a trailing backslash run is doubled as well + * (otherwise an odd run would escape the closing quote into a literal + * character and corrupt the rest of the command line). Mirrors the CRT + * ArgvQuote behavior Microsoft documents for command-line arguments. * @param argument - one argv entry to quote. * @returns the quoted entry (bare when quoting is unnecessary). */ @@ -30,10 +33,13 @@ export function quoteArg(argument: string): string { backslashes++ index++ } - if (index < argument.length && argument.charAt(index) === '"') { + if (index === argument.length) { + // Trailing backslash run: doubled so it cannot escape the closing quote. + quoted += '\\'.repeat(backslashes * 2) + } else if (argument.charAt(index) === '"') { quoted += '\\'.repeat(backslashes * 2 + 1) + '"' } else { - quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '') + quoted += '\\'.repeat(backslashes) + argument.charAt(index) } } return quoted + '"' @@ -119,8 +125,19 @@ export function spawnSandboxed( null, options.cwd, startupInfo, processInfo, ) - // Capture the failure before CloseHandle calls clobber GetLastError. - if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`) + // Capture the failure before CloseHandle calls clobber GetLastError, then + // close every pipe handle created so far — the six-close contract this test + // surface pins (tests/failure-paths.spec.ts). + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(stdIn.read) + api.closeHandle(stdIn.write) + api.closeHandle(stdOut.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.read) + api.closeHandle(stdErr.write) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } const info = decodeProcessInfo(processInfo) const processHandle = info.hProcess @@ -172,7 +189,9 @@ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise< } chunks.push(chunk.subarray(0, decodeUint32(readSlot))) } - await new Promise(resolve => setImmediate(resolve)) + // Small backoff instead of setImmediate: a bare next-tick would busy-poll + // the pipe at full event-loop speed while the child produces no output. + await new Promise(resolve => setTimeout(resolve, 1)) } api.closeHandle(handle) return Buffer.concat(chunks) @@ -314,7 +333,16 @@ export function spawnSandboxedInherited( api.closeHandle(job) throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) } - if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`) + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) { + // Closing the job triggers kill-on-close, so the suspended child dies + // instead of hanging until this process exits; the process/thread handles + // must go too. + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } api.closeHandle(threadHandle) return { pid: info.dwProcessId, process: processHandle, job } diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 7696605c4e..942862ceb9 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -49,13 +49,26 @@ export const SE_GROUP_LOGON_ID = 0xC0000000 export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL /** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ export const FILE_GENERIC_WRITE = 0x00120116 -// What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as -// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +/** DELETE: remove or rename the object (winnt.h line ~3009). */ +export const DELETE = 0x00010000 +/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */ +export const FILE_DELETE_CHILD = 0x0040 +// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The +// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined +// delete/rename/git operations inside the granted trees pass the token's +// access check too; Write+DELETE displays as "Modify" in icacls. +// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the +// child take ownership or rewrite DACLs and escape the allowlist (the +// security boundary). /** - * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL — the write-access mask - * the orphan-SID ACEs grant (displays as "Write" in Explorer/icacls). + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and + * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant + * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are + * deliberately excluded: they would let the confined child take ownership or + * rewrite DACLs. */ -export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 +export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 // CreateRestrictedToken flags (winnt.h lines ~4284) /** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ @@ -68,7 +81,13 @@ export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) /** WinWorldSid: S-1-1-0 (Everyone). */ export const WinWorldSid = 1 -/** WinLocalSid: S-1-2-0 (LOCAL); CreateWellKnownSid(WinLocalSid) fails with ERROR_INVALID_PARAMETER on Windows 11 build 26200. */ +/** + * WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init + * (it sits in every restricted token's restricting list). The + * CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this + * module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to + * this type. + */ export const WinLocalSid = 2 /** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ export const WinInteractiveSid = 11 @@ -155,6 +174,39 @@ export const ERROR_INSUFFICIENT_BUFFER = 122 export const ERROR_BROKEN_PIPE = 109 /** ERROR_NO_DATA: the pipe is being closed. */ export const ERROR_NO_DATA = 232 +/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */ +export const ERROR_LOCK_VIOLATION = 33 + +// ---- lock files (fileapi.h / minwinbase.h / winnt.h) ----------------------- + +// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is +// enough to take byte-range locks. +/** GENERIC_READ: generic read access (winnt.h line ~3028). */ +export const GENERIC_READ = 0x80000000 +/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */ +export const GENERIC_WRITE = 0x40000000 +// CreateFileW dwShareMode: the lock file is shared for read/write but NOT +// for delete — if a locked file could be deleted and recreated underneath the +// lock holder, two processes could hold "the same" lock on different files. +/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */ +export const FILE_SHARE_READ = 0x00000001 +/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */ +export const FILE_SHARE_WRITE = 0x00000002 +/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */ +export const FILE_SHARE_DELETE = 0x00000004 +/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */ +export const OPEN_ALWAYS = 4 +// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h). +/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */ +export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 +/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ +export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 + +// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when +// reading a DACL are marked with this bit and are not part of the explicit +// DACL edits this module makes. +/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */ +export const INHERITED_ACE = 0x10 // ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts new file mode 100644 index 0000000000..fe0bc13acc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -0,0 +1,234 @@ +/** + * ACL edit tests: the read-merge-write grant keeps pre-existing explicit + * ACEs, interleaved sandbox instances do not clobber each other, the + * per-path lock primitive is deterministic, and the grant mask carries + * DELETE + FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER). + * + * All state lives in %TEMP% mkdtemp scratch directories; the only exception + * is the mandated lock infrastructure under \dsh-acl-locks, + * whose per-test lock file is removed in cleanup. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import koffi from 'koffi' + +import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' +import { AclSandbox } from '../src/index.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const isWin32 = process.platform === 'win32' + +/** FILE_READ_DATA (winnt.h line ~5895): the harmless mask the explicit test ACE grants. */ +const FILE_READ_DATA = 0x0001 + +/** koffi SID layout: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes, big-endian), subAuthority@8. */ +const SID_STRUCT = koffi.struct('DSH_ACL_SPEC_SID', { + revision: 'uint8', + subAuthorityCount: 'uint8', + identifierAuthority: 'uint8[6]', + subAuthority: 'uint32[8]', +}) + +interface SidLayout { + revision: number + subAuthorityCount: number + identifierAuthority: number[] + subAuthority: number[] +} + +/** One direct (explicit, non-inherited) allow ACE of a directory DACL. */ +interface DirectAce { + sid: string + mask: number +} + +/** Convert one SID string to a LocalAlloc'd SID pointer (caller frees). */ +function sidFromString(api: Win32Bindings, sid: string): NativePtr { + const slot = allocPtrSlot() + if (api.convertStringSidToSidW(sid, slot) === 0) throw new Error(`ConvertStringSidToSidW failed for ${sid}`) + const ptr = decodePtr(slot) + if (ptr === null) throw new Error(`ConvertStringSidToSidW returned null for ${sid}`) + return ptr +} + +/** Stringify a decoded SID layout (identifierAuthority bytes 2..5 are the big-endian value). */ +function sidString(sid: SidLayout): string { + const authority = ((sid.identifierAuthority[2] ?? 0) << 24) + | ((sid.identifierAuthority[3] ?? 0) << 16) + | ((sid.identifierAuthority[4] ?? 0) << 8) + | (sid.identifierAuthority[5] ?? 0) + const subs = sid.subAuthority.slice(0, sid.subAuthorityCount).join('-') + return `S-${sid.revision}-${authority}${sid.subAuthorityCount > 0 ? `-${subs}` : ''}` +} + +/** + * Read the directory's explicit allow ACEs (inherited ACEs excluded): each + * ACE header is AceType@0, AceFlags@1, AceSize@2 (winnt.h lines ~3477-3480); + * ACCESS_ALLOWED_ACE stores Mask@4 and the inline SID@8. The ACL pointer sits + * inside the descriptor allocation — only the descriptor is LocalFree'd. + */ +function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if ((flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + +describe.skipIf(!isWin32)('ACL editing', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')) + scratchDirs.push(dir) + return dir + } + + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { + const api = await win32() + const dir = scratch() + const usersSid = sidFromString(api, 'S-1-5-32-545') + const orphanSid = sidFromString(api, 'S-1-4-4242-1') + try { + // Install one explicit ACE (Users + benign read mask) with the + // package's own bindings, exactly like a pre-existing explicit DACL + // entry another sandbox instance or administrator added. + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(usersSid, abi.GRANT_ACCESS, FILE_READ_DATA), null, newAclSlot) + expect(mergeResult, `SetEntriesInAclW setup (${mergeResult})`).toBe(abi.ERROR_SUCCESS) + const newAcl = decodePtr(newAclSlot) + expect(newAcl).not.toBeNull() + const applyResult = api.setNamedSecurityInfoW( + dir, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, null, null, newAcl, null, + ) + const freed = newAcl === null ? null : api.localFree(newAcl) + expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) + expect(isNullPtr(freed)).toBe(true) + + grantWrite(api, dir, orphanSid) + revokeWrite(api, dir, orphanSid) + + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + } finally { + if (!isNullPtr(usersSid)) api.localFree(usersSid) + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { + const api = await win32() + const dir = scratch() + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2' }) + await sandboxA.init() + await sandboxB.init() + sandboxA.dispose() + sandboxB.dispose() + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false) + }) + + it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + const open = (): NativePtr => api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + const first = open() + const second = open() + expect(isInvalidHandle(first)).toBe(false) + expect(isInvalidHandle(second)).toBe(false) + try { + expect(api.lockFileEx(first, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(0) + expect(api.getLastError()).toBe(abi.ERROR_LOCK_VIOLATION) + expect(api.unlockFileEx(first, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(second, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(first) + api.closeHandle(second) + rmSync(lockPath, { force: true }) + } + }) + + it('withPathLock serializes the action and releases the lock even when the action throws', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + let attempts = 0 + expect(() => withPathLock(api, dir, () => { + attempts++ + throw new Error('action failure') + })).toThrow('action failure') + expect(attempts).toBe(1) + // The lock was released: a fresh immediate lock succeeds. + const handle = api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + expect(isInvalidHandle(handle)).toBe(false) + try { + expect(api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(handle, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(handle) + rmSync(lockPath, { force: true }) + } + }) + + it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { + const api = await win32() + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5' }) + try { + await sandbox.init() + const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') + expect(grant).toBeDefined() + const mask = grant?.mask ?? 0 + expect(mask).toBe(abi.GRANT_MASK) + expect(mask & abi.DELETE).toBe(abi.DELETE) + expect(mask & abi.FILE_DELETE_CHILD).toBe(abi.FILE_DELETE_CHILD) + expect(mask & 0x00040000).toBe(0) // WRITE_DAC must never be granted + expect(mask & 0x00080000).toBe(0) // WRITE_OWNER must never be granted + } finally { + sandbox.dispose() + } + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts new file mode 100644 index 0000000000..14f370602c --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -0,0 +1,112 @@ +/** + * Failure-path unit tests with minimal stub binding tables: the spawn + * helpers must close every handle they created before throwing, and + * getTempPath must refuse to decode a buffer GetTempPathW never wrote. + * Pure stubs — no real Win32 calls, so these run on every platform. + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ +function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(() => 0), + getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ +function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn failure paths close their handles', () => { + // A dummy token value; the stubbed spawn never reads it. + const token = 1n as NativePtr + + it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { + const { api, closed, closeHandle } = pipeFailureApi() + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect((caught as Win32Error).win32Code).toBe(5) + expect(closeHandle).toHaveBeenCalledTimes(6) + expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) + }) + + it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { + const { api, closed, closeHandle } = resumeFailureApi() + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('ResumeThread') + expect((caught as Win32Error).win32Code).toBe(5) + // thread, process, job — closing the job triggers kill-on-close so the + // suspended child dies instead of hanging until this process exits. + expect(closeHandle).toHaveBeenCalledTimes(3) + expect(closed).toEqual([201n, 200n, 100n]) + }) +}) + +describe('getTempPath buffer defense', () => { + it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => { + const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer + expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts new file mode 100644 index 0000000000..5af00fb9cb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts @@ -0,0 +1,88 @@ +/** + * quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW + * parser (shell32.dll, shellapi.h line ~867: + * `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32. + * + * CommandLineToArgvW applies the documented backslash rule (2n backslashes + * before a quote produce n backslashes and toggle quoting; 2n+1 produce n + * backslashes and a literal quote) to every token EXCEPT the first — the + * first token is parsed with backslashes literal and quotes toggling + * (verified empirically on this machine, Windows 11 build 26200). The + * round-trip therefore prepends a plain program token, exactly like + * buildCommandLine's real callers do, so the arguments under test land on + * the rule-applying tokens. + * + * Reading argv from CommandLineToArgvW: koffi cannot decode the returned + * LPWSTR* contents directly (the pointed-to strings are not koffi-registered + * references), so each string is copied with lstrcpynW (winbase.h line + * ~1500) into a Node Buffer and read as UTF-16LE; lengths come from + * lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree + * (winbase.h line ~1127) — CommandLineToArgvW's documented contract. + */ + +import { describe, expect, it } from 'vitest' + +import { buildCommandLine, quoteArg } from '../src/spawn.ts' + +const isWin32 = process.platform === 'win32' + +/** + * Table cases: input argv entry → the exact command-line fragment quoteArg + * must produce. Trailing-backslash inputs are the regression: the closing + * quote must be preceded by DOUBLED backslashes, or the parser reads them as + * escaping the closing quote. + */ +const cases: Array<[input: string, quoted: string]> = [ + ['', '""'], + ['a', 'a'], + ['a b', '"a b"'], + ['a"b', '"a\\"b"'], + ['a\\b', 'a\\b'], + ['a b\\', '"a b\\\\"'], + ['a b\\\\', '"a b\\\\\\\\"'], + ['a b\\\\\\', '"a b\\\\\\\\\\\\"'], + ['a\\\\"b', '"a\\\\\\\\\\"b"'], +] + +describe('quoteArg', () => { + it.each(cases)('quotes %j as %j', (input, quoted) => { + expect(quoteArg(input)).toBe(quoted) + }) +}) + +describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { + it('parses quoteArg+join back to the exact original argv', async () => { + const { default: koffi } = await import('koffi') + const PVOID = koffi.pointer('void') + const shell32 = koffi.load('shell32.dll') + const kernel32 = koffi.load('kernel32.dll') + const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')]) + const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) + const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) + const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) + + const parse = (commandLine: string): string[] => { + const countSlot = koffi.alloc('int', 1) as unknown + const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown + try { + if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') + const count = koffi.decode(countSlot, 0, 'int') as number + const table = Buffer.from(koffi.view(argvBlock, count * 8)) + const parsed: string[] = [] + for (let index = 0; index < count; index++) { + const stringAddress = table.readBigUInt64LE(index * 8) + const copied = Buffer.alloc(2048) + lstrcpynW(copied, stringAddress, copied.length / 2) + const length = lstrlenW(copied) as number + parsed.push(copied.subarray(0, length * 2).toString('utf16le')) + } + return parsed + } finally { + localFree(argvBlock) + } + } + + const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b'] + expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index f188423425..680d65b253 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -12,16 +12,16 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) +// Functional probe, not where.exe: spawnSync never throws on a missing +// binary (status null) and where.exe exits 1 without pwsh — only an actual +// pwsh invocation's exit status is truth. function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } function runRunner(args: string[], timeoutMs = 30_000) { @@ -99,6 +99,31 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) }, 30_000) + it('workspace-write: Remove-Item and Rename-Item succeed in the granted workspace (DELETE + FILE_DELETE_CHILD)', () => { + // Deleting a file and renaming a directory both hit the second access + // check on the workspace itself: the grant must carry DELETE (on the + // object) and FILE_DELETE_CHILD (on its parent). + const victimFile = join(writableDir, 'delete-me.txt') + writeFileSync(victimFile, 'remove me') + const victimDir = join(writableDir, 'rename-me') + mkdirSync(victimDir) + const renamedDir = join(writableDir, 'renamed-by-child') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Remove-Item -LiteralPath '${victimFile}' -ErrorAction Stop;'DELETE-FILE: OK'}catch{'DELETE-FILE: DENIED'};`, + `try{Rename-Item -LiteralPath '${victimDir}' -NewName 'renamed-by-child' -ErrorAction Stop;'RENAME-DIR: OK'}catch{'RENAME-DIR: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('DELETE-FILE: OK') + expect(result.stdout).toContain('RENAME-DIR: OK') + expect(existsSync(victimFile)).toBe(false) + expect(existsSync(renamedDir)).toBe(true) + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 914326acdc..866fda7160 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -88,6 +88,20 @@ int wmain() P(FILE_GENERIC_WRITE); P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); P(STANDARD_RIGHTS_WRITE); + P(DELETE); + P(FILE_DELETE_CHILD); + P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); + + P(FILE_SHARE_READ); + P(FILE_SHARE_WRITE); + P(FILE_SHARE_DELETE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_ALWAYS); + P(LOCKFILE_EXCLUSIVE_LOCK); + P(LOCKFILE_FAIL_IMMEDIATELY); + P(ERROR_LOCK_VIOLATION); + P(INHERITED_ACE); P(DISABLE_MAX_PRIVILEGE); P(SANDBOX_INERT); @@ -163,7 +177,14 @@ int wmain() static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); - static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "grant mask"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask"); + static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); + static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); + static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); + static_assert(OPEN_ALWAYS == 4, "open always"); + static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); + static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); + static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b06386f144..d08966c17a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4654,6 +4654,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../sandbox-local From abfb9336208a8051f24f9a1e0d354fc311eb63ea Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:29:43 +0800 Subject: [PATCH 26/62] feat(sandbox): per-session windows-acl write grant with dual-mode restricting lists and a private temp subdirectory --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 7 + docs/core-data-structures/sandbox.zh.md | 7 + docs/persistence-catalog.md | 23 ++ knip.json | 1 + packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 2 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/sandbox/sandbox-local/package.json | 2 + .../sandbox/sandbox-local/src/acl-session.ts | 103 +++++++ packages/sandbox/sandbox-local/src/index.ts | 154 +++++++++- .../sandbox-local/tests/acl-session.spec.ts | 275 ++++++++++++++++++ packages/sandbox/sandbox-local/tsconfig.json | 6 + packages/sandbox/sandbox-policy/src/index.ts | 1 + .../sandbox-policy/tests/policy.spec.ts | 4 + .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 32 +- .../sandbox/sandbox-windows-acl/README.zh.md | 32 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 63 +++- .../sandbox/sandbox-windows-acl/src/ffi.ts | 89 +++++- .../sandbox/sandbox-windows-acl/src/grant.ts | 95 ++++++ .../sandbox/sandbox-windows-acl/src/index.ts | 60 +++- .../sandbox/sandbox-windows-acl/src/runner.ts | 45 ++- .../sandbox/sandbox-windows-acl/src/spawn.ts | 6 +- .../sandbox/sandbox-windows-acl/src/token.ts | 39 ++- .../sandbox-windows-acl/src/win32-abi.ts | 9 + .../sandbox-windows-acl/tests/acl.spec.ts | 30 +- .../tests/grant-failure-paths.spec.ts | 100 +++++++ .../sandbox-windows-acl/tests/grant.spec.ts | 69 +++++ .../sandbox-windows-acl/tests/probe.spec.ts | 4 +- .../sandbox-windows-acl/tests/runner.spec.ts | 50 +++- packages/sandbox/sandbox/src/index.ts | 7 + 40 files changed, 1239 insertions(+), 118 deletions(-) create mode 100644 packages/sandbox/sandbox-local/src/acl-session.ts create mode 100644 packages/sandbox/sandbox-local/tests/acl-session.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/grant.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 9bb02e92b6..7a11b5e95f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: db793ab31a853a6627b2e30f4dd3f71542e3836d -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a3d69a2dd94de2fa4ef83aa16ee297c1939b767d +2026-08-08-windows-acl-restricted-token-sandbox.md: e93a066aca1a39fb177a7ae3c2f71f44724befd7 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index db793ab31a..e93a066aca 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID, Everyone, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented). ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation — Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index a3d69a2dd9..e4d5254504 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 登录 SID、Everyone、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录)。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4697e1267..f29675a109 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1181,7 +1181,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2019,7 +2019,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:47`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..cc645fa531 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:155`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index be2c311fd1..dd31511e20 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/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 docs/core-data-structures/sandbox.md -sandbox.md: af2b9043c52f35b7f38b368de1420d0e2856a963 -sandbox.zh.md: 5638e76769639525b1884756805d0e0cef1e870e +sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da +sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index af2b9043c5..a9a1fec080 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 5638e76769..99505265a9 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 48732dbbb0..c0e25df4cb 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -477,6 +477,29 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ### `sandbox/*` +#### `sandbox/acl-session` — log-only + +```ts persistence-catalog +/** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ +'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string +} +``` + +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) + #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/knip.json b/knip.json index f90e6c8f8d..e938425ffd 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "icacls", "python3", "sandbox-exec", "taskkill", diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 19f2c66290..cd371b4878 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: de89e8c5d1fcf78fcd47ebdb98c1d1fc3bb0bd5f -README.zh.md: 45ab3d9fa3c750984d903475b3c9e027e9e95d1c +README.md: 2fb5c7cdec44d023abd31b0913acdeead9e54eaf +README.zh.md: d8368dbd371be0f4b7530ba956b7cd7507486115 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index de89e8c5d1..2fb5c7cdec 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **The Windows temp grant is the real temp directory** — `workspace-write` confines writes to the workspace plus the host temp area (the same backend-defined choice the Landlock rung makes); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 45ab3d9fa3..d8368dbd37 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 的临时目录授权是真实 temp 目录**——`workspace-write` 把写入限制在工作区与宿主 temp 区域(与 Landlock 档位相同的后端定义选择);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..7e2e8e5c39 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', }, { name: 'SandboxMode', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1ad29d7d01..6db4d0b297 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -176,7 +176,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace', sessionId: 'agent' }, }]) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57b0ebb8e..c11a40ebc6 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -124,7 +124,7 @@ describe('pty-local real shell', () => { const created = await ctx.pty.spawn(agent, { type: 'shell' }) expect(sandbox.calls).toEqual([{ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) }, + policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' }, }]) await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 060026e284..c52b9c66fa 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts new file mode 100644 index 0000000000..c820694b11 --- /dev/null +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -0,0 +1,103 @@ +/** + * The windows-acl per-session write identity — the DURABLE half of the seam's + * per-session grant reuse. Each session owns exactly one record (one orphan + * write SID, one private temp subdirectory), stored as a log-only + * `sandbox/acl-session` event on the session log (the `sandbox/mode` + * precedent): replayable, never in the model transcript, and no external + * config store. The ACE half is server-lifetime state owned by the provider + * ({@link AclWriteGrant} materialization, revoked on dispose); the record + * survives restarts so a resumed session reuses the SAME SID — re-granting + * idempotently merges into (or skips) the standing ACEs instead of leaking a + * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a + * fresh record; the record's workspace must match the session's immutable + * cwd (asserted by the provider). + * + * @module dsh-sandbox-local/acl-session + */ + +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ + 'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string + } + } +} + +/** The durable per-session record carried by one `sandbox/acl-session` event. */ +export interface AclSessionRecord { + /** The orphan write SID whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the record was provisioned for. */ + workspace: string + /** The session's private temp subdirectory. */ + tempDir: string +} + +/** + * The session's windows-acl record: the last `sandbox/acl-session` event in + * the log, or undefined when the session has none (never confined on + * Windows). 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 last provisioned record, or undefined without one. + */ +export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'sandbox/acl-session') return event.data + } + return undefined +} + +/** + * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges + * across server restarts (the same SID re-grants the same directory) and OS + * temp hygiene may reclaim it — deliberately no GC here. + * @param sessionId - the session identity. + * @returns the private temp subdirectory path. + */ +export function sessionTempDir(sessionId: string): string { + const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) + return join(tmpdir(), `dsh-${digest}`) +} + +/** + * Provision the record for a session that has none (its first Windows + * confined execution): a fresh write SID plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (new session id) provisions a fresh record; resume replays the + * stored one. + * @param session - the session the record belongs to. + * @param workspaceRoot - the resolved policy root (the session's immutable cwd). + * @returns the provisioned record. + */ +export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { + const record: AclSessionRecord = { + writeSid: randomWriteSid(), + workspace: workspaceRoot, + tempDir: sessionTempDir(session.id), + } + session.append('sandbox/acl-session', record) + return record +} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 0b562616ea..183c6200ed 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -4,11 +4,18 @@ * competing candidates once, and reports each wrap's enforcement and stderr * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. + * + * The windows-acl rung additionally owns the per-session write grant: one + * orphan write SID and one private temp subdirectory per session (durable + * record in the session log — see `./acl-session.ts`), ACEs materialized + * lazily at the session's first confined execution and held for the SERVER + * process's lifetime (revoked on dispose). The runner receives `--write-sid` + * and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { @@ -22,6 +29,10 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId } from '@deepseek-ai/dsh-session' +import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { provisionAclSession, sessionAclRecord } from './acl-session.ts' +import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -205,9 +216,10 @@ const RUNNER_FAILURE_RULES = { } as const satisfies Record /** - * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless - * apart from the cached chain verdict — it spawns nothing but the one-time - * probes, so there is no disposal work beyond cordis' own. + * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the + * chain verdict and, on the windows-acl rung, the per-session write grants + * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the + * one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -225,6 +237,12 @@ export class LocalSandboxProvider extends SandboxProvider { private readonly probeTimeoutMs: number /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined + /** + * Server-lifetime per-session write grants (windows-acl rung), keyed by the + * session's orphan write SID — the native half of the per-session reuse; + * the durable half lives in the session log (`./acl-session.ts`). + */ + private readonly aclGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -246,6 +264,12 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) + // Standing ACL grants are revoked with the provider: a clean server + // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs + // the session's durable record re-grants idempotently on resume). + ctx.effect(() => () => { + this.revokeAclGrants() + }) } /** @@ -284,18 +308,124 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] - case 'windows-acl': return [ - ...this.windowsAclRunnerInvocation(), - '--workspace', policy.workspaceRoot, - // Explicit, never GetTempPathW-defaulted: the runner grants exactly - // this directory (workspace-write) or nothing (read-only). - '--temp', tmpdir(), - '--mode', policy.mode, - ] + case 'windows-acl': return this.windowsAclRunnerArgv(policy) default: return assertNever(runner) } } + /** + * The windows-acl runner argv for one policy. With a calling session + * (the policy's `sessionId`), the session's durable record is folded from + * the session log (provisioned on first use), its ACEs materialized once + * per server lifetime, and the runner receives `--write-sid` plus the + * session's PRIVATE temp subdirectory — it grants nothing and revokes + * nothing. Agentless calls (no session) pass no SID: the runner + * self-manages per-call grants on the ambient temp root. + * @param policy - the resolved per-call policy. + * @returns the runner invocation. + */ + private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { + const sessionId = policy.sessionId + const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) + if (record !== undefined) this.materializeAclGrant(record, policy.mode) + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Workspace-write sessions confine their temp writes to the PRIVATE + // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only + // and agentless runs pass the ambient temp root — the runner validates + // it exists but grants nothing (or self-manages, agentless only). + '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + '--mode', policy.mode, + ...record === undefined ? [] : ['--write-sid', record.writeSid], + ] + } + + /** + * Fold (or provision) the calling session's durable windows-acl record. + * The provision appends exactly one log-only `sandbox/acl-session` event + * to the session log; the record's workspace must equal the policy root — + * both derive from the session's immutable cwd, so a mismatch is a + * corrupted composition and fails loud. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's record. + */ + private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + const store = this.ctx.get('sessions') + if (store === undefined) { + throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') + } + const session = store.get(SessionId(sessionId)) + if (session === undefined) { + throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) + } + const existing = sessionAclRecord(session.events) + if (existing !== undefined) { + if (existing.workspace !== workspaceRoot) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} ` + + `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`, + ) + } + return existing + } + return provisionAclSession(session, workspaceRoot) + } + + /** + * Materialize the record's ACEs once per server lifetime: lazily at the + * session's first confined execution, reused for every later call (the map + * hit is the whole call). Workspace-write grants the workspace root and + * the private temp subdirectory (created here); read-only materializes + * NOTHING — its token alone restricts every write. Fail-closed: a + * half-materialized grant is revoked before the error propagates. + * @param record - the session's durable record. + * @param mode - the policy mode (grants exist only under workspace-write). + */ + private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { + if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return + const grant = AclWriteGrant.create(record.writeSid) + try { + mkdirSync(record.tempDir, { recursive: true }) + grant.add(record.workspace) + grant.add(record.tempDir) + } catch (error) { + // Revoke whatever stands and free the SID — never leave a half-grant + // behind a failed confine (the runner never runs). + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + } + throw error + } + this.aclGrants.set(record.writeSid, grant) + } + + /** + * Revoke every standing per-session grant and free every SID (provider + * dispose). Cleanup failures are reported, not thrown: cordis teardown + * must not be aborted by grant revocation, and the durable records make a + * missed revocation self-healing on the next resume. + */ + private revokeAclGrants(): void { + if (this.aclGrants.size === 0) return + const failures: unknown[] = [] + for (const grant of this.aclGrants.values()) { + try { + grant.dispose() + } catch (error) { + failures.push(error) + } + } + this.aclGrants.clear() + if (failures.length > 0) { + this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) + for (const error of failures) this.ctx.logger.warn(error) + } + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts new file mode 100644 index 0000000000..b39451d8ea --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -0,0 +1,275 @@ +/** + * The windows-acl per-session grant: the DURABLE record (session-log event + * fold/provision) plus the SERVER-LIFETIME ACE materialization + * ({@link AclWriteGrant}), exercised through the REAL + * LocalSandboxProvider.confine() with a real session store. The Win32 surface + * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), + * so these assertions run in every CI lane that runs sandbox-local's suites; + * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- + * acl's own tests on win32 hosts. + */ + +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { sessionTempDir } from '../src/acl-session.ts' + +/** Cross-file state shared with the vi.mock factory (hoisting contract). */ +const mockState = vi.hoisted(() => ({ + grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + addFailure: undefined as Error | undefined, + disposeFailure: undefined as Error | undefined, +})) + +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { + class MockAclWriteGrant { + readonly writeSid: string + readonly added: string[] = [] + disposed = false + constructor(writeSid: string) { + this.writeSid = writeSid + mockState.grants.push(this) + } + static create(writeSid: string): MockAclWriteGrant { + return new MockAclWriteGrant(writeSid) + } + add(path: string): void { + if (mockState.addFailure !== undefined) throw mockState.addFailure + this.added.push(path) + } + dispose(): void { + if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure + this.disposed = true + } + } + return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } +}) + +/** One provisioned record event, shaped like the live log's envelope. */ +function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { + return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + return { ctx, sandbox, fiber } +} + +/** A workspace root the policy, the record, and the session cwd all share. */ +function workspaceRoot(): string { + return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) +} + +describe('windows-acl per-session grant (LocalSandboxProvider)', () => { + const scratch: string[] = [] + + beforeEach(() => { + mockState.grants = [] + mockState.addFailure = undefined + mockState.disposeFailure = undefined + }) + + const cleanup = () => { + for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) + } + + it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-1') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', privateTemp, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) + expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + // Reuse: the SECOND confine is the map hit — no new grant, no new event. + const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(second.argv).toEqual(confined.argv) + expect(mockState.grants).toHaveLength(1) + expect(session.events).toHaveLength(1) + + // Provider dispose revokes the standing grant. + await fiber.dispose() + expect(mockState.grants[0]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-ro') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + + const confined = sandbox.confine(['true'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--mode', 'read-only', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + try { + const ws = workspaceRoot() + scratch.push(ws) + const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } + scratch.push(record.tempDir) + + const first = await setup() + const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) + // The constructor appends the `session/end-seed` marker, so the log is + // the seed plus that marker — exactly one acl record among them. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const confined = first.sandbox.confine(['true'], policy) + expect(confined.argv).toContain('S-1-4-77-1') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) + // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + expect(session.events).toHaveLength(2) + } finally { + cleanup() + } + }) + + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('fails loud without the session store, and when the policy names a session the store does not hold', async () => { + try { + const bare = new Context() + await bare.plugin(LocalSandboxProvider, {}) + const sandbox = bare.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) + + const { sandbox: withStore } = await setup() + expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/) + } finally { + cleanup() + } + }) + + it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + + // add() throws on the FIRST path: the cleanup dispose() runs and the + // original error propagates unchanged. + mockState.addFailure = new Error('grant exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + + // add() AND dispose() both throw: both surface as an AggregateError. + mockState.grants = [] + mockState.addFailure = new Error('grant exploded again') + mockState.disposeFailure = new Error('cleanup exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => { + try { + const { sandbox, fiber } = await setup() + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(0) + // Disposing a provider with no grants is a no-op (the empty-map guard). + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + + mockState.disposeFailure = new Error('revoke exploded') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) + } finally { + cleanup() + } + }) +}) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 608f0e9568..9effc139c3 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -23,6 +23,12 @@ { "path": "../sandbox" }, + { + "path": "../sandbox-windows-acl" + }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 4c87cbc8b3..3a3aac1d70 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + ...session === undefined ? {} : { sessionId: session.id }, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index c535847f58..11552f2a3a 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + sessionId: 'sess-first', }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + sessionId: 'sess-second', }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') @@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + sessionId: 'sess-symlink-parent', }) } finally { rmSync(root, { recursive: true, force: true }) @@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + sessionId: 'sess-approved', }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ad25c7e95f..875a1a441d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: d4f99c44f0542e9ed6e7d4605730ad0f0311a22c -README.zh.md: 9b51ccf7b29aefc3dea9a1c97bad75c2f41f5f98 +README.md: 1969515f6692eda059fd5e83449d68c6a4da5c4f +README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index d4f99c44f0..1969515f66 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -Modes: -- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. +Modes (the token's restricting-SID list follows the mode): +- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. +- `read-only` (list I = logon SID, Everyone, orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. ## Header verification @@ -56,9 +60,11 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. -- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. +- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -70,7 +76,9 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Each confined command mutates two directory DACLs** — a grant on entry and a revoke on exit, on the workspace root and the temp root: a handful of Win32 calls per command (inheritance is evaluated lazily per access, not a per-file walk). The runner pays this per command; reusing one grant per session is deferred work if the churn ever matters. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. +- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. +- **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9b51ccf7b2..4c751ba6dc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 ## 隔离 runner 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -模式: -- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 +模式(令牌的 restricting SID 列表随模式而定): +- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 +- `read-only`(列表 I = 登录 SID、Everyone、孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 ## 头文件查证 @@ -56,9 +60,11 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 +- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 ## 模型体验 @@ -70,7 +76,9 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ## 已知限制与后续工作 -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **每次受限命令都会改动两个目录的 DACL** —— 进入时授权、退出时撤销,分别作用在工作区根与临时根:每次命令若干次 Win32 调用(继承按访问惰性求值,不是逐文件遍历)。runner 按命令付这笔开销;按会话复用一次授权留作后续工作,待开销真的成为问题再实现。 +- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 +- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 +- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 7700474d5f..ef787cc410 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto' import { mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' -import { allocOverlapped, allocPtrSlot, decodePtr, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' @@ -26,6 +26,10 @@ import * as abi from './win32-abi.ts' * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which * removes every ACE for the trustee. + * @param sidPtr - the trustee SID the entry names. + * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). + * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @returns the packed entry buffer. */ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) @@ -174,13 +178,52 @@ function mergeAndApply( if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) } +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the + * orphan SID). Every field is read through koffi.decode at pointer offsets — + * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the + * ACE after the 4-byte mask — there is no pointer to read; reading one + * yields garbage addresses and crashed EqualSid, verified by gdb), so it is + * compared field-by-field against the orphan SID through bounded offset + * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" + * so the caller falls back to the merge-apply path, which owns the robust + * failure handling. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). + * @param sidPtr - the orphan write SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path + let offset = 8 // the first ACE follows the 8-byte ACL header + for (let index = 0; index < aceCount; index++) { + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); + // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path + const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false +} + /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID - * on `path`, inheriting to subcontainers and objects. Read-merge-write: the - * new ACE merges into the directory's CURRENT explicit DACL (same shape as - * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the - * per-path lock. The directory must be owned by the caller (owner implicit - * WRITE_DAC) — same precondition as the POC. + * on `path`, inheriting to subcontainers and objects. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE (the + * per-session grant surviving from a previous server lifetime), the + * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate + * the identical ACE across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write: the new ACE merges into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL gains the grant (the workspace or temp root). * @param sidPtr - the orphan write SID the ACE names. @@ -188,6 +231,14 @@ function mergeAndApply( export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { withPathLock(api, path, () => { const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { + // The exact ACE stands: releasing the descriptor is the whole operation. + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) + } + return + } mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 1d9665a8fc..dd0124edda 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -70,7 +70,6 @@ export interface Win32Bindings { localFree(memory: NativePtr): NativePtr // ---- SIDs ---------------------------------------------------------------- convertStringSidToSidW(stringSid: string, sid: NativePtr): number - convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number isValidSid(sid: NativePtr): number getLengthSid(sid: NativePtr): number @@ -111,6 +110,7 @@ export interface Win32Bindings { inheritHandles: number, creationFlags: number, environment: null, currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, ): number + setEnvironmentVariableW(name: string, value: string): number readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, buffer: null, size: number, @@ -219,16 +219,6 @@ export function decodeUint32(slot: NativePtr): number { return value as number } -/** - * Decode a UTF-16 string at a pointer. - * @param ptr - pointer to the NUL-terminated UTF-16 string. - * @returns the decoded string. - */ -export function decodeStr16(ptr: NativePtr): string { - const value: unknown = koffi.decode(ptr, 'str16') - return value as string -} - /** * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). * @param ptr - the koffi pointer. @@ -272,6 +262,69 @@ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { return value as NativePtr } +/** + * Decode a uint8 at a native pointer plus byte offset — the ACL walk's + * field-read primitive (koffi.decode with an offset, no memcpy, no pointer + * arithmetic). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint8. + */ +export function decodeUint8At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint8') + return value as number +} + +/** + * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint16. + */ +export function decodeUint16At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint16') + return value as number +} + +/** + * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint32. + */ +export function decodeUint32At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint32') + return value as number +} + +/** + * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, + * identifier authority, subauthorities up to the count) — never a fixed-size + * struct decode, which would read past a short SID allocation (a SID with + * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible + * subauthority count reads as unequal. + * @param left - pointer to one SID (offset 0). + * @param leftOffset - byte offset of the SID structure within `left`. + * @param right - pointer to the other SID. + * @param rightOffset - byte offset of the SID structure within `right`. + * @returns whether the SIDs are identical. + */ +export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { + const leftRevision = decodeUint8At(left, leftOffset) + const rightRevision = decodeUint8At(right, rightOffset) + if (leftRevision !== rightRevision) return false + const leftCount = decodeUint8At(left, leftOffset + 1) + const rightCount = decodeUint8At(right, rightOffset + 1) + if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false + for (let index = 0; index < 6; index++) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + } + for (let index = 0; index < leftCount; index++) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + } + return true +} + /** * Allocate a zeroed STARTUPINFOW. * @returns the allocated struct pointer. @@ -331,7 +384,6 @@ function bindings(): Win32Bindings { localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), - convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), @@ -356,6 +408,7 @@ function bindings(): Win32Bindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), @@ -378,6 +431,18 @@ export function win32(): Promise { return Promise.resolve(bindings()) } +/** + * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's + * server-side per-session grant materializes ACEs inside the synchronous + * `confine()` call, which cannot await. Same cached table as {@link win32} + * (the underlying koffi loads are synchronous; the async wrapper exists for + * the runner's await-shaped call sites). + * @returns the cached binding table. + */ +export function win32Sync(): Win32Bindings { + return bindings() +} + /** * Turn a Win32 error code into readable text via FormatMessageW. * @param api - the binding table. diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts new file mode 100644 index 0000000000..4a0f6f4dec --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -0,0 +1,95 @@ +/** + * Server-side per-session write grant: the ACE materialization half of the + * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE + * {@link AclWriteGrant} per session for the server process's lifetime — + * created lazily at the session's first confined execution, reused (never + * re-applied) for every later call, revoked on provider dispose. The durable + * half (the session's SID and paths surviving a restart) lives in the + * session log, owned by the seam; this module owns only the native half: the + * parsed SID pointer and the standing ACEs. + * + * Fail-closed: `add` throws on any grant failure and the caller disposes the + * instance (revoking every path granted so far); `dispose` revokes every + * standing grant and reports every cleanup failure. + * @module @deepseek-ai/dsh-sandbox-windows-acl/grant + */ + +import { grantWrite, revokeWrite } from './acl.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' + +/** + * One orphan write SID's server-lifetime grant materialization: the parsed + * SID pointer plus every directory whose DACL currently carries its ACE. + * Create with {@link AclWriteGrant.create}; dispose revokes all. + */ +export class AclWriteGrant { + /** The orphan write SID in SDDL string form. */ + readonly writeSid: string + private readonly api: Win32Bindings + private readonly sidPtr: NativePtr + private readonly grantedPaths: string[] = [] + + private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + this.api = api + this.sidPtr = sidPtr + this.writeSid = writeSid + } + + /** + * Parse the SID string and open the binding table (lazily, once per + * server). Fail-closed: any failure throws — nothing is granted yet. + * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param api - optional already-resolved bindings (tests). + * @returns the ready grant (no ACEs yet). + */ + static create(writeSid: string, api?: Win32Bindings): AclWriteGrant { + const bindings = api ?? win32Sync() + const sidSlot = allocPtrSlot() + if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) { + throwLastError(bindings, 'ConvertStringSidToSidW', writeSid) + } + const sidPtr = decodePtr(sidSlot) + if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) + return new AclWriteGrant(bindings, sidPtr, writeSid) + } + + /** + * Grant the write ACE on one directory (idempotent: an already-standing + * exact ACE skips the eager full-tree re-propagation — see + * {@link grantWrite}) and record the path for {@link dispose}. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. + * @param path - the directory whose DACL gains the grant. + */ + add(path: string): void { + grantWrite(this.api, path, this.sidPtr) + this.grantedPaths.push(path) + } + + /** Every directory currently carrying the grant, in grant order. */ + get paths(): readonly string[] { + return this.grantedPaths + } + + /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + dispose(): void { + const failures: unknown[] = [] + for (const path of this.grantedPaths) { + try { + revokeWrite(this.api, path, this.sidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = this.api.localFree(this.sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e312a40e09..1ff712f24e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -19,7 +19,10 @@ * - grants are standing ACE mutations on real directories — revoke them via * dispose() before the process exits (the POC's documented * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). + * this module's revoke instead). With `manageDacls: false` the CALLER owns + * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() + * skip grant/revoke entirely and the caller must not revoke under live + * children. * @module @deepseek-ai/dsh-sandbox-windows-acl */ @@ -36,6 +39,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' +export { AclWriteGrant } from './grant.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -50,6 +54,21 @@ export interface AclSandboxOptions { tempDir?: string | null /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ writeSid?: string + /** + * The file-effect mode this instance confines under — selects the + * restricted token's restricting-SID list (I for read-only, J for + * workspace-write) and MUST match the grant shape: read-only pairs with + * zero grants. The runner validates the argv-borne mode string at its + * boundary; this typed seam trusts the union. + */ + mode: 'read-only' | 'workspace-write' + /** + * Whether this instance owns its DACL grants (default true). False means + * the CALLER has already materialized the ACEs (the sandbox seam's + * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * the caller holds the grants for its own lifetime and revokes them. + */ + manageDacls?: boolean } /** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ @@ -84,7 +103,10 @@ export interface AclSandboxChild { wait(): Promise } -function randomWriteSid(): string { +/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). + * @returns the SDDL string form. + */ +export function randomWriteSid(): string { return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` } @@ -92,14 +114,18 @@ function randomWriteSid(): string { * One write-restricted sandbox instance: token + orphan-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes whatever was granted * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. + * failure. With `manageDacls: false` the caller owns the grants (per-session + * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string + /** The file-effect mode — the restricted token's restricting-SID list selection. */ + readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined + private readonly manageDacls: boolean private tempDirResolved: string | null | undefined private api: Win32Bindings | undefined private token: NativePtr | undefined @@ -107,6 +133,8 @@ export class AclSandbox { private grantedPaths: string[] = [] constructor(options: AclSandboxOptions) { + this.mode = options.mode + this.manageDacls = options.manageDacls ?? true this.writableDirs = options.writableDirs.map((directory) => { const absolute = resolve(directory) if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { @@ -149,9 +177,14 @@ export class AclSandbox { this.tempDirResolved = tempDir } - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - grantWrite(api, path, writeSidPtr) - this.grantedPaths.push(path) + // manageDacls: false — the caller (the sandbox seam's per-session grant) + // already materialized the ACEs; this instance must neither add nor + // remove any (its dispose() must not revoke the caller's standing grant). + if (this.manageDacls) { + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } } const logonSid = findLogonSid(api, currentToken) const restricted = createRestrictedToken( @@ -159,9 +192,8 @@ export class AclSandbox { { world: makeWellKnownSid(api, abi.WinWorldSid), authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), - interactive: makeWellKnownSid(api, abi.WinInteractiveSid), - local: makeWellKnownSid(api, abi.WinLocalSid), }, + this.mode, ) this.token = restricted if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') @@ -248,11 +280,13 @@ export class AclSandbox { const failures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) + if (this.manageDacls) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } } } try { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 578a615f84..a6be67fc3f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -8,13 +8,30 @@ * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , - * '--mode', , '--', ] + * '--mode', , + * ['--write-sid', ], '--', ] * * Modes: * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); documented in README. + * NUL device (`> $null` fails with access denied); the token's restricting + * list also drops Authenticated Users (CIM unavailable — documented in + * README). + * + * `--write-sid`: the seam's per-session grant contract — the CALLER has + * already materialized the orphan-SID ACEs (once per session, server + * lifetime) and owns their revocation, so the runner neither grants nor + * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) + * the runner self-manages grants per invocation as before. With + * `--write-sid` in workspace-write mode, the runner rewrites the TMP/TEMP + * entries of its OWN environment (SetEnvironmentVariableW) to the `--temp` + * directory — a PRIVATE per-session temp subdirectory the seam provisions + * (bwrap `--tmpfs /tmp` semantics) — and the child inherits the rewritten + * block (lpEnvironment NULL; an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW, verified empirically). + * Read-only leaves the ambient temp entries untouched (writes there are + * denied anyway). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -43,6 +60,7 @@ interface ParsedArgs { workspace: string temp: string mode: 'read-only' | 'workspace-write' + writeSid: string | undefined command: string args: string[] } @@ -51,6 +69,7 @@ function parseArgs(raw: string[]): ParsedArgs { let workspace: string | undefined let temp: string | undefined let mode: string | undefined + let writeSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -65,6 +84,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--workspace': workspace = value; break case '--temp': temp = value; break case '--mode': mode = value; break + case '--write-sid': writeSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -74,7 +94,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -101,9 +121,28 @@ async function main(): Promise { const sandbox = new AclSandbox({ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + mode: parsed.mode, + ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, + // With --write-sid the seam owns the DACLs (per-session grants): this + // invocation must neither add nor revoke ACEs. + manageDacls: parsed.writeSid === undefined, }) await sandbox.init() + // The seam's per-session temp contract: under --write-sid, workspace-write + // children see the PRIVATE per-session temp subdirectory through TMP/TEMP + // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment + // (SetEnvironmentVariableW) and the child inherits the block; self-managed + // and read-only runs keep the ambient entries. + if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { + if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + try { const child = sandbox.spawn({ command: parsed.command, diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 9c0436f29f..b71efe2ac7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -87,7 +87,11 @@ export interface SpawnedNative { /** * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends - * are returned for draining. + * are returned for draining. The child inherits the caller's environment block + * (lpEnvironment NULL); the caller rewrites entries through + * SetEnvironmentVariableW before spawning (the runner's per-session temp + * contract) — passing an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). * @param api - the binding table. * @param token - the restricted token the child runs under. * @param options - command, args, and working directory. diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 569c428551..009592bb28 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -105,22 +105,33 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { export interface RestrictingSidSet { world: NativePtr authUser: NativePtr - interactive: NativePtr - local: NativePtr } /** - * Create the write-restricted token. Ordering matters: EVERYONE first (the - * POC's note — the intersection check hits it on most objects), then the - * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan - * write SID that forms the write allowlist. S-1-2-1 (console logon) is - * intentionally absent: see win32-abi.ts for the verified failure modes. - * FAILS CLOSED: any failure throws — never spawn unrestricted. + * Create the write-restricted token with the mode-selected restricting list + * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant + * harness): + * - list I (read-only): [logon SID, EVERYONE, orphan] + * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * + * The logon SID and EVERYONE are shared: they keep the early startup chain + * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh + * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in + * list J ONLY because the CIM path's WMI namespace security check requires it + * (0x80041003 otherwise) — read-only drops it for a zero ambient-write + * surface (it closes the host's C:\-root tree-creation escape, where + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM + * unavailability; documented in README. INTERACTIVE/LOCAL are absent from + * BOTH lists — the host's Public tree grants write to INTERACTIVE, so + * removing it closes that escape. S-1-2-1 (console logon) is intentionally + * absent: see win32-abi.ts for the verified failure modes. FAILS CLOSED: any + * failure throws — never spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. * @param writeSid - the orphan SID forming the write allowlist. * @param known - the well-known SIDs entering the restricting list. + * @param mode - selects the restricting list (I for read-only, J for workspace-write). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -129,15 +140,11 @@ export function createRestrictedToken( logonSid: NativePtr, writeSid: NativePtr, known: RestrictingSidSet, + mode: 'read-only' | 'workspace-write', ): NativePtr { - const restrictingSids = buildRestrictingSids([ - known.world, - logonSid, - known.authUser, - known.interactive, - known.local, - writeSid, - ]) + const restrictingSids = buildRestrictingSids(mode === 'read-only' + ? [logonSid, known.world, writeSid] + : [logonSid, known.world, known.authUser, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 942862ceb9..b96acf4942 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -202,6 +202,15 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 /** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 +// ACE_HEADER.AceType (winnt.h lines ~3449-3463) +/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +export const ACCESS_ALLOWED_ACE_TYPE = 0 + +// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 +// #define SID_MAX_SUB_AUTHORITIES 15). +/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +export const SID_MAX_SUB_AUTHORITIES = 15 + // ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when // reading a DACL are marked with this bit and are not part of the explicit // DACL edits this module makes. diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index fe0bc13acc..2e4c5baab7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -12,7 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' @@ -148,11 +148,33 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) + it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + const api = await win32() + const dir = scratch() + const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const apply = vi.spyOn(api, 'setNamedSecurityInfoW') + try { + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + // The exact ACE now stands (the per-session grant surviving from a + // previous server lifetime): the second grant is a DACL read only. + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + const aces = readDirectAces(api, dir) + expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + revokeWrite(api, dir, orphanSid) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) + } finally { + apply.mockRestore() + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { const api = await win32() const dir = scratch() - const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1' }) - const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2' }) + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() sandboxA.dispose() @@ -216,7 +238,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { const api = await win32() const dir = scratch() - const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5' }) + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' }) try { await sandbox.init() const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts new file mode 100644 index 0000000000..fe803025b9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -0,0 +1,100 @@ +/** + * AclWriteGrant failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * dispose aggregates revocation and SID-free failures into an + * AggregateError. Pure stubs — no real Win32 calls, so these run on every + * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import koffi from 'koffi' + +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { AclWriteGrant } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ +function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { + const state = { failReads: false } + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 2), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, failReads: () => { state.failReads = true } } +} + +describe('AclWriteGrant failure paths', () => { + it('create fails closed: a SID parse failure throws before anything is granted', () => { + const api = { + convertStringSidToSidW: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-abc-1', api)).toThrow(/ConvertStringSidToSidW/) + }) + + it('create fails closed: a null SID pointer is rejected', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 0n) + return 1 + }), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/null SID/) + }) + + it('dispose aggregates a failing revocation into an AggregateError (best-effort cleanup)', () => { + const { api, failReads } = grantThenFailApi() + const grant = AclWriteGrant.create('S-1-4-42-42', api) + grant.add('C:\\granted') + expect(grant.paths).toEqual(['C:\\granted']) + failReads() + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) + + it('dispose aggregates a failing SID free into an AggregateError', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const grant = AclWriteGrant.create('S-1-4-42-42', api) + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts new file mode 100644 index 0000000000..02090e2fe9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -0,0 +1,69 @@ +/** + * AclWriteGrant tests: the server-side per-session grant materialization — + * SID parsing fail-closed, ACE add/dispose round-trip against the REAL + * directory DACL (observed through icacls, the operator's own tool), and + * the recorded path order. Win32-only, like the other real-FFI suites. + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { AclWriteGrant } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +/** The directory DACL as icacls renders it (the operator-visible form). */ +function icaclsText(path: string): string { + const result = spawnSync('icacls', [path], { encoding: 'utf8' }) + expect(result.status, `icacls failed: ${result.stderr}`).toBe(0) + return result.stdout +} + +describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-grant-')) + scratchDirs.push(dir) + return dir + } + + it('create parses the SID fail-closed: a malformed SID throws before anything is granted', () => { + expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) + }) + + it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + const dir = scratch() + const grant = AclWriteGrant.create('S-1-4-9000-77') + grant.add(dir) + expect(grant.paths).toEqual([dir]) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + // A second add over the standing exact ACE is a DACL-read no-op: the + // grant stays exactly one ACE (per-session reuse after a restart). + grant.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + grant.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + }) + + it('two grants with different SIDs coexist and revoke independently', () => { + const dir = scratch() + const grantA = AclWriteGrant.create('S-1-4-9000-78') + const grantB = AclWriteGrant.create('S-1-4-9000-79') + grantA.add(dir) + grantB.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantA.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantB.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-79') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index e370316b79..0eb78e16f4 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // block, which host runtimes (vitest worker pools) may not keep in sync // with process.env — and a real-temp grant would inherit over every // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, mode: 'workspace-write' }) await sandbox.init() }) @@ -91,7 +91,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 680d65b253..8ddd10c474 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { AclWriteGrant } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -59,7 +60,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List J carries Authenticated Users: the CIM path (WMI namespace + // security check) stays alive under workspace-write. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', @@ -70,11 +74,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: OK') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -84,7 +89,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List I drops Authenticated Users: the WMI namespace security check + // fails (0x80041003) — the documented read-only CIM boundary, the + // price of the zero ambient-write surface. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', @@ -96,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('NUL-WRITE: DENIED') expect(result.stdout).toContain('DOLLAR-NULL: OK') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) }, 30_000) @@ -124,6 +134,40 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) + it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { + const writeSid = 'S-1-4-9000-99' + const privateTemp = join(isolatedTemp, 'private-subdir') + mkdirSync(privateTemp) + const grant = AclWriteGrant.create(writeSid) + grant.add(privateTemp) + try { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, + "'TEMP-ENV: ' + $env:TEMP;", + "'TMP-ENV: ' + $env:TMP", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + // The runner granted nothing (only the caller's private-temp grant + // stands): the workspace write is denied, the private temp write lands, + // and the child's TMP/TEMP point at the private subdirectory. + expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') + expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') + expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) + expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) + expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) + } finally { + grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index aeca1ba8d6..6170482a37 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,6 +40,13 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } /** From 43d53d339df5272bace694224d18e11e7928e4e7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:30:04 +0800 Subject: [PATCH 27/62] fix(pwsh): stamp the calling session's sandbox policy onto pwsh tool calls --- docs/module-graph.md | 7 +- packages/bash/tool-pwsh/package.json | 4 + packages/bash/tool-pwsh/src/index.ts | 27 ++++-- packages/bash/tool-pwsh/tests/tools.spec.ts | 101 +++++++++++++++++++- packages/bash/tool-pwsh/tsconfig.json | 6 ++ 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index dddf086bb0..be87d4147d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -445,6 +445,7 @@ flowchart TD pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -868,6 +869,8 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools @@ -1232,7 +1235,7 @@ flowchart TD | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1313,7 +1316,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 0c25317faa..625ae29c30 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -30,6 +30,8 @@ "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -46,6 +48,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index a68d5d2e35..e557e56248 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,11 +4,13 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: - * foreground and `run_in_background` execution (background handles register - * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment - * through the shared `bash-env` registry, and the bash marker/truncation - * rendering story. UI presentation mirrors the bash tool's too: a completed + * Behavior mirrors `dsh-tool-bash` call-for-call minus the escalation + * surface: foreground and `run_in_background` execution (background handles + * register with the generic `ctx.tasks` runtime), the managed `DSH_*` + * environment through the shared `bash-env` registry, the per-call sandbox + * policy resolution (the calling session's mode and cwd travel to the + * confining executor), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed * foreground call is a terminal card with the parsed exit-status pill, using * the shared exit-status parse from `@deepseek-ai/dsh-bash`. * @@ -19,12 +21,14 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' @@ -141,6 +145,14 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const defaultMode = ctx.bash.sandboxMode + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) ctx.systemPrompt.section({ name: 'tool:pwsh', @@ -224,12 +236,15 @@ export function apply(ctx: Context, config: Config = {}): void { /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) + // Description is display metadata; workdir defaults to the caller's session. + const standingPolicy = resolveSandboxPolicy(exec) const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), + ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 71e3124e7e..454c244c69 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' @@ -24,6 +24,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' @@ -150,9 +151,59 @@ async function setupWithTasks(toolConfig: Partial = {}, dshHome return { ctx, bash } } +/** + * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve + * the calling session's standing policy and stamp it on the request, exactly + * like the bash tool — the per-session sandbox-policy regression surface. + */ +class ConfiningFakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + + override get sandboxMode() { + return 'read-only' as const + } + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(_spec: BashExecSpec): Promise { + return runResult('ok\n') + } + + override start(_spec: BashExecSpec): BashProcess { + return fakeProcess() + } +} + +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool. */ +async function setupSandboxed(toolConfig: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SandboxPolicyService, {}) + await ctx.plugin(ConfiningFakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as ConfiningFakeBash + return { ctx, bash } +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + * The fake session carries an empty event log (the sandbox-policy resolver + * folds the log for mode overrides, mirroring a real session). */ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const scopeFiber = ctx.plugin(() => {}) @@ -160,7 +211,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -397,6 +448,52 @@ describe('execution through the bash seam', () => { }) }) +describe('per-call sandbox policy resolution', () => { + it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { + const { ctx, bash } = await setupSandboxed() + const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + const agent = registerFakeAgent(ctx, 'policy-session') + Object.assign(agent.session.header, { cwd: sessionCwd }) + const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) + expect(result.isError).toBe(false) + // The policy's workspace root is the session cwd canonicalized by the + // policy service (realpath + resolve), NEVER the web server's launch dir; + // the calling session's identity rides along for backend per-session state. + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(sessionCwd)), + sessionId: 'policy-session', + }) + }) + + it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => { + const { ctx, bash } = await setupSandboxed() + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(process.cwd())), + }) + + // The base FakeBash advertises no sandboxMode, so the tool must not stamp + // any policy (the executor defaulting stays the executor's own). + const plain = await setup() + await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy') + }) + + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(ConfiningFakeBash) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow( + 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing', + ) + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 61b2c69448..2d383d22fa 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../support/invariants" } From 07c7e2f0bd626b4d1138e6e87714beb19d270de1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:39:25 +0800 Subject: [PATCH 28/62] fix(sandbox): revoke post-apply grant failures, free init SID allocations, and name the Windows runner in SANDBOX_UNAVAILABLE --- .../sandbox/sandbox-windows-acl/src/grant.ts | 11 +++--- .../sandbox/sandbox-windows-acl/src/index.ts | 35 ++++++++++++++++--- packages/sandbox/sandbox/README.i18n.yaml | 4 +-- packages/sandbox/sandbox/README.md | 2 +- packages/sandbox/sandbox/README.zh.md | 2 +- packages/sandbox/sandbox/src/index.ts | 5 +-- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 4a0f6f4dec..7826922f1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -57,14 +57,17 @@ export class AclWriteGrant { /** * Grant the write ACE on one directory (idempotent: an already-standing * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose}. Callers - * treat a throw as a failed materialization and dispose the instance to - * revoke the paths granted so far. + * {@link grantWrite}) and record the path for {@link dispose}. The path is + * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after + * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an + * ungranted path is a no-op merge. Callers treat a throw as a failed + * materialization and dispose the instance to revoke the paths granted so + * far. * @param path - the directory whose DACL gains the grant. */ add(path: string): void { - grantWrite(this.api, path, this.sidPtr) this.grantedPaths.push(path) + grantWrite(this.api, path, this.sidPtr) } /** Every directory currently carrying the grant, in grant order. */ diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 1ff712f24e..07f35ed177 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -130,6 +130,8 @@ export class AclSandbox { private api: Win32Bindings | undefined private token: NativePtr | undefined private writeSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private sidAllocations: NativePtr[] = [] private grantedPaths: string[] = [] constructor(options: AclSandboxOptions) { @@ -182,16 +184,24 @@ export class AclSandbox { // remove any (its dispose() must not revoke the caller's standing grant). if (this.manageDacls) { for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - grantWrite(api, path, writeSidPtr) + // Record BEFORE granting: grantWrite can throw after a successful + // apply (a LocalFree failure), and the fail-closed catch must still + // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push(path) + grantWrite(api, path, writeSidPtr) } } const logonSid = findLogonSid(api, currentToken) + this.sidAllocations.push(logonSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(worldSid) + const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid) + this.sidAllocations.push(authUserSid) const restricted = createRestrictedToken( api, currentToken, logonSid, writeSidPtr, { - world: makeWellKnownSid(api, abi.WinWorldSid), - authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), + world: worldSid, + authUser: authUserSid, }, this.mode, ) @@ -201,7 +211,8 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave standing grants behind a failed init. + // Fail-closed cleanup: never leave standing grants or SID allocations + // behind a failed init. const cleanupFailures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { @@ -213,6 +224,14 @@ export class AclSandbox { } } } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } if (cleanupFailures.length > 0) { throw new AggregateError( [error, ...cleanupFailures], @@ -304,6 +323,14 @@ export class AclSandbox { failures.push(error) } } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (error) { + failures.push(error) + } + } this.api = undefined this.token = undefined this.writeSidPtr = undefined diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 11f2a2ed86..6f3d1ca4dd 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/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/README.md -README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 -README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa +README.md: d6bf873d559fe0933e7e7ee7d966e2f27053ca05 +README.zh.md: a618fe435d0c9d862c8dab4ded390de45e1e5d74 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 1b522b2c72..d6bf873d55 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -23,7 +23,7 @@ Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-b ##### Exact error ```markdown -sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token effect diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index adccd4421a..a618fe435d 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -23,7 +23,7 @@ ##### 精确错误 ```markdown -sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token 影响 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 6170482a37..fee9164ead 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -131,8 +131,9 @@ export class SandboxUnavailableError extends HarnessError { super( `sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; ` + 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing ' - + 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement ' - + 'backend yet — or switch the consumer to danger-full-access.' + + 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL ' + + 'restricted-token runner can start (Windows) — otherwise switch the consumer to ' + + 'danger-full-access.' + (detail === undefined ? '' : ` Runner failure: ${detail}`), SANDBOX_UNAVAILABLE, ) From c0991f256861cbe0809a95540a8b5b830c1a4ad3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:58:35 +0800 Subject: [PATCH 29/62] fix(docs): point the parity note at the implemented windows-pwsh-default note and refresh catalogs after the merge-forward --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- docs/cordis-catalog/services.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 51401adfdc..156d215124 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 -2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc +2026-08-05-pwsh-ui-bash-parity.md: 91951d96b3832de7e6ec1e5207fd9794dd48e120 +2026-08-05-pwsh-ui-bash-parity.zh.md: c050c91acca632df7b37ceef363203f575c78832 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 815b448b89..592c3c9223 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 967c5a9e14..82a8dbb87e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 665cb66afa..abebea09b5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:155`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:156`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` From 644162d4df3061ac0753025e6cf417d86c40e928 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:03:15 +0800 Subject: [PATCH 30/62] fix(docs): re-record the parity note pair hashes after the EOF normalization --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 156d215124..5ad32eb099 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 91951d96b3832de7e6ec1e5207fd9794dd48e120 -2026-08-05-pwsh-ui-bash-parity.zh.md: c050c91acca632df7b37ceef363203f575c78832 +2026-08-05-pwsh-ui-bash-parity.md: 592c3c922308d53f1bb83ff3ea371c38b99d5be7 +2026-08-05-pwsh-ui-bash-parity.zh.md: 82a8dbb87ed60ca7528b76c6b0d66872d9edc7f2 From f1af1831efeb1106860ddc09ddd4a53f0d4b7c6c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:10:21 +0800 Subject: [PATCH 31/62] fix(docs): point the parity note at the implemented windows-pwsh-default note and re-record the pair --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 51401adfdc..5ad32eb099 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 -2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc +2026-08-05-pwsh-ui-bash-parity.md: 592c3c922308d53f1bb83ff3ea371c38b99d5be7 +2026-08-05-pwsh-ui-bash-parity.zh.md: 82a8dbb87ed60ca7528b76c6b0d66872d9edc7f2 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 815b448b89..592c3c9223 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 967c5a9e14..82a8dbb87e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision From e9b1c64a38b4dca04575300ecf1a451ba43b9900 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:16:19 +0800 Subject: [PATCH 32/62] fix(sandbox): update the missing-runner snapshot for the Windows-aware SANDBOX_UNAVAILABLE text --- .../tests/snapshots/missing-sandbox-runner/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl index f9e30bb24d..de0171618e 100644 --- a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785916902468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785916902468,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d588acd6-d0ab-43c5-9e18-67fe3f625e48"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785916902469,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} -{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785916902487,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785916902496,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 69fd67bfd826e143449cd778926e39de7584a99f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:25:48 +0800 Subject: [PATCH 33/62] fix(docs): restore the cli reference pairing record to the multi-line contract after the merge-forward --- apps/cli/reference/README.i18n.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 722725bbdf..f11f4de700 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -1 +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 apps/cli/reference/README.mdREADME.md: 2a0a0d5ef62e3e89c6cdaf04f9fd63de2f882ce1README.zh.md: 69d3f4f78c50cce35e82039bedd10506c4d0a9a1 \ No newline at end of file +# 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 apps/cli/reference/README.md +README.md: 2a0a0d5ef62e3e89c6cdaf04f9fd63de2f882ce1 +README.zh.md: 69d3f4f78c50cce35e82039bedd10506c4d0a9a1 From 2dd9af6f4b0b9354c428a3d0822f76068a6d557e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:11:01 +0800 Subject: [PATCH 34/62] fix(sandbox): keep read-only strictly zero-grant by dropping the write-allowlist SID from list I MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that materialized its workspace-write grant and then switched to read-only (or crashed and resumed under read-only) kept a writable workspace for the server lifetime: the standing orphan-SID ACE survived the downgrade and list I still carried the orphan, so the write-restricted pass-2 check granted it. List I is now [logon SID, EVERYONE] only — the standing ACE stays inert under read-only while the unrevoked ACE keeps the re-upgrade free (map hit, no re-propagation). Pinned by the runner's real-token mode-downgrade regression (standing ACE denied under read-only, effective again on re-upgrade), the acl-session mode-switch cycle (nothing under read-only, one materialization on upgrade, no revoke on downgrade), and ConstrainedLanguage pins in both modes. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 8 ++- .../sandbox-local/tests/acl-session.spec.ts | 62 +++++++++++++++++++ .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 3 +- .../sandbox/sandbox-windows-acl/README.zh.md | 3 +- .../sandbox/sandbox-windows-acl/src/token.ts | 23 ++++--- .../sandbox-windows-acl/tests/runner.spec.ts | 46 ++++++++++++++ 10 files changed, 143 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 7a11b5e95f..3b28349045 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: e93a066aca1a39fb177a7ae3c2f71f44724befd7 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619 +2026-08-08-windows-acl-restricted-token-sandbox.md: 4459e121c09fe566efc9a35604df10dae223928c +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index e93a066aca..4459e121c0 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID, Everyone, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID + Everyone only — no orphan and no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed, and a standing grant ACE from an earlier workspace-write period stays INERT: the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free) and list J (`workspace-write` = + Authenticated Users + orphan, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented). +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation — Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — and the ConstrainedLanguage pins in both modes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index e4d5254504..307f39d366 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 登录 SID、Everyone、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 仅登录 SID + Everyone——不含孤儿 SID 与 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭,且先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**:pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)与 list J(`workspace-write` = + Authenticated Users + 孤儿 SID,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录)。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 ## Related diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 596665cffb..fb1a76cc9c 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -378,8 +378,12 @@ export class LocalSandboxProvider extends SandboxProvider { * session's first confined execution, reused for every later call (the map * hit is the whole call). Workspace-write grants the workspace root and * the private temp subdirectory (created here); read-only materializes - * NOTHING — its token alone restricts every write. Fail-closed: a - * half-materialized grant is revoked before the error propagates. + * NOTHING — its token alone restricts every write, and a standing grant + * from an earlier workspace-write period is KEPT through a downgrade + * (never revoked): the read-only restricted token carries no orphan SID + * (list I), so the ACE is inert there, while the map hit keeps the + * re-upgrade free of re-propagation. Fail-closed: a half-materialized + * grant is revoked before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index b39451d8ea..7ebb5ff2ed 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -150,6 +150,68 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-switch') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' } + + // Read-only first: the record still rides along (--write-sid, one + // event) but NOTHING is materialized and the ambient temp root is + // passed — the map stays empty, so the later upgrade must materialize. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), + '--mode', 'read-only', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(privateTemp)).toBe(false) + + // Upgrade: the FIRST workspace-write confine materializes the grant + // (the map was empty — read-only never wrote it) with the SAME SID + // and the private temp dir, so the upgrade path cannot dead-end. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', privateTemp, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) + expect(existsSync(privateTemp)).toBe(true) + + // Reuse: the second workspace-write call is the map hit. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(1) + + // Downgrade: the standing grant is KEPT — never revoked, never + // re-granted. The read-only restricted token's list I carries no + // orphan SID (pinned by the windows-acl runner regression), so the + // ACE is inert under read-only while the map hit keeps the + // re-upgrade free of eager propagation. + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { try { const ws = workspaceRoot() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 875a1a441d..80a35821d4 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 1969515f6692eda059fd5e83449d68c6a4da5c4f -README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54 +README.md: 2e3a16fa84541ffb2eb442c953777f37407e1b46 +README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 1969515f66..2e3a16fa84 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -42,7 +42,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t Modes (the token's restricting-SID list follows the mode): - `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. -- `read-only` (list I = logon SID, Everyone, orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. +- `read-only` (list I = logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The orphan stays OUT of list I on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. @@ -82,3 +82,4 @@ None directly; the denial surface belongs to the tool layer. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. +- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 4c751ba6dc..e58b70a120 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -42,7 +42,7 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接 模式(令牌的 restricting SID 列表随模式而定): - `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 -- `read-only`(列表 I = 登录 SID、Everyone、孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 +- `read-only`(列表 I = 登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。孤儿 SID 有意留在列表 I **之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 `AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 @@ -82,3 +82,4 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 - **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 +- **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 009592bb28..2b114a6567 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -111,7 +111,7 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant * harness): - * - list I (read-only): [logon SID, EVERYONE, orphan] + * - list I (read-only): [logon SID, EVERYONE] * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] * * The logon SID and EVERYONE are shared: they keep the early startup chain @@ -121,15 +121,22 @@ export interface RestrictingSidSet { * (0x80041003 otherwise) — read-only drops it for a zero ambient-write * surface (it closes the host's C:\-root tree-creation escape, where * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM - * unavailability; documented in README. INTERACTIVE/LOCAL are absent from - * BOTH lists — the host's Public tree grants write to INTERACTIVE, so - * removing it closes that escape. S-1-2-1 (console logon) is intentionally - * absent: see win32-abi.ts for the verified failure modes. FAILS CLOSED: any - * failure throws — never spawn unrestricted. + * unavailability; documented in README. List I also carries NO orphan: a + * standing grant ACE from an earlier workspace-write period (a + * `/permission` mode downgrade, or a crash-resumed session) must stay INERT + * under read-only — the WRITE_RESTRICTED pass-2 check grants only what the + * restricting list carries, so omitting the orphan keeps read-only strictly + * zero-grant even with stale ACEs standing, while the unrevoked ACE keeps + * the re-upgrade free (the seam's grant map hits it — no re-propagation). + * INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree + * grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1 + * (console logon) is intentionally absent: see win32-abi.ts for the + * verified failure modes. FAILS CLOSED: any failure throws — never spawn + * unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist. + * @param writeSid - the orphan SID forming the write allowlist (list J only). * @param known - the well-known SIDs entering the restricting list. * @param mode - selects the restricting list (I for read-only, J for workspace-write). * @returns the restricted token handle. @@ -143,7 +150,7 @@ export function createRestrictedToken( mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' - ? [logonSid, known.world, writeSid] + ? [logonSid, known.world] : [logonSid, known.world, known.authUser, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 8ddd10c474..18af8a8ffd 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -57,6 +57,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('workspace-write: the confined child writes granted directories only', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", + // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes + // (documented Known Limitation) — pinned here so a token change that + // silently restores FullLanguage is caught. + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -70,6 +74,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -100,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') expect(result.stdout).toContain('TARGET-WRITE: DENIED') expect(result.stdout).toContain('TEMP-WRITE: DENIED') expect(result.stdout).toContain('NUL-WRITE: DENIED') @@ -168,6 +174,46 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { + // The reported defect: a session that materialized its grant in + // workspace-write keeps the ACE standing for the server lifetime. After + // switching to read-only, the restricted token's list I must carry NO + // orphan SID — the standing ACE stays but the pass-2 check cannot use + // it, so the workspace write is denied (previously it LEAKED). The + // switch back reuses the SAME standing ACE: the re-upgrade write lands + // without any re-grant. + const writeSid = 'S-1-4-9001-7' + const grant = AclWriteGrant.create(writeSid) + grant.add(writableDir) + try { + const downgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, + ].join('') + const downgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, + ]) + expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) + expect(downgraded.stdout).toContain('DOWNGRADE-WRITE: DENIED') + expect(existsSync(join(writableDir, 'downgraded.txt'))).toBe(false) + + const reupgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, + ].join('') + const reupgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, + ]) + expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) + expect(reupgraded.stdout).toContain('REUPGRADE-WRITE: OK') + expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) + } finally { + grant.dispose() + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) From 83f835e4cb12be728e0140bb282bca49d2e8244b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:11:21 +0800 Subject: [PATCH 35/62] =?UTF-8?q?feat(pwsh):=20mirror=20the=20bash=20sandb?= =?UTF-8?q?ox=20surface=20=E2=80=94=20denial=20rendering,=20escalation,=20?= =?UTF-8?q?and=20the=20ConstrainedLanguage=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool-pwsh now renders sandbox denial facts (denial marker + same-turn escalation hint, runner-failed notice on background reads) and advertises the sandbox_permissions/justification escalation pair resolved through ctx.approval before execution — the shared fail-closed sequence from dsh-sandbox, with the tool name 'pwsh'. The description teaches the ConstrainedLanguage contract under the Windows sandbox (both confined modes run pwsh in CLM: Add-Type, non-core .NET statics, COM, and reflection fail; the mode cannot be lifted — probe-verified, pinned by LANGMODE assertions in the runner suite and description assertions in the tool suite). The stale pwsh-sandbox JSDoc ('the tool owns approval') and the parity notes' superseded 'minus sandbox' claims are corrected. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 8 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 8 +- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 +- .../2026-08-02-pwsh-tool-bash-parity.md | 6 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 6 +- docs/config-catalog.md | 4 +- docs/module-graph.md | 3 +- docs/tool-catalog.md | 2 +- packages/bash/pwsh-sandbox/src/index.ts | 15 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 16 +- packages/bash/tool-pwsh/README.zh.md | 16 +- packages/bash/tool-pwsh/package.json | 2 + packages/bash/tool-pwsh/src/index.ts | 140 ++++++++-- packages/bash/tool-pwsh/src/render.ts | 52 +++- packages/bash/tool-pwsh/tests/tools.spec.ts | 257 +++++++++++++++++- packages/bash/tool-pwsh/tsconfig.json | 6 + 18 files changed, 471 insertions(+), 82 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index efd7d56dae..45c2df39d0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.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-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 77f0a13d0efa55c91b4e58ee2474ac6877947c80 -2026-08-01-pwsh-tool-and-executor.zh.md: 95c0d8f087f11d192350cf92ed866bb8a4ea33b4 +2026-08-01-pwsh-tool-and-executor.md: a511035d959ed873d75af87e9f56374dd4cd4f27 +2026-08-01-pwsh-tool-and-executor.zh.md: c0e6a73228e08f2b5f2d5a6ccfb9a6eecce3ba7d diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 77f0a13d0e..a511035d95 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -6,14 +6,14 @@ English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) ## Problem -The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool is also larger than a Windows-first profile strictly needs — the persistent-PTY twin in particular is bash-shaped surface the `pwsh` tool still does not carry. The original minimal profile also left out background tasks and sandbox escalation: background arrived with the [parity decision](2026-08-02-pwsh-tool-bash-parity.md), and the sandbox surface (denial rendering plus `sandbox_permissions` escalation) arrived with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the minimal tool was sized for the danger-full-access Windows posture, and that premise ended when the sandbox PR re-enabled confinement and approval on Windows. ## Decision Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus a Windows-specific ConstrainedLanguage contract in the tool description. The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. @@ -23,13 +23,13 @@ The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash of **Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. -**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the model-visible contract is the dialect itself (paths, variables, exit facts differ), so a dialect parameter would either churn the schema conditionally or force one tool to teach two dialects; the separate twin keeps the model contract honest — and carries the shared surfaces (background, sandbox, rendering) by mirroring rather than by sharing an implementation. **Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage boundary precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index 95c0d8f087..c0e6a73228 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具也大于 Windows 优先画像的严格所需——尤其持久 PTY 孪生是 `pwsh` 工具至今仍不背负的 bash 形状表面。最初的最小画像也没有后台任务与沙箱升级:后台随 [parity 决策](2026-08-02-pwsh-tool-bash-parity.md) 到来,沙箱面(拒绝渲染加 `sandbox_permissions` 升级)随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 到来——最小工具当初按 danger-full-access 的 Windows 姿态裁剪,这一前提在 sandbox PR(Pull Request)于 Windows 上重新启用隔离与审批时终结。 ## 决策 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 契约。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 @@ -23,13 +23,13 @@ Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道 **给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 -**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 +**给 `dsh-tool-bash` 增加方言参数。** 否决:模型可见契约本身就是方言(路径、变量、退出事实都不同),因此方言参数要么让 schema 按条件翻动,要么逼一个工具教两种方言;独立的孪生让模型契约保持诚实——并以镜像而非共享实现的方式携带共享表面(后台、沙箱、渲染)。 **现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇与 ConstrainedLanguage 边界。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 40e24e9ac2..8dd8add3bd 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade -2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a +2026-08-02-pwsh-tool-bash-parity.md: 9b1e04e3c460f8aaefa5eb34449cca57a966b600 +2026-08-02-pwsh-tool-bash-parity.zh.md: 5c9dc70f259f483a841129724f46bf5d9e758655 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index e35a903892..9b1e04e3c4 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -10,13 +10,13 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Decision -`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, and its model-visible text describes exactly that behavior: - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. - **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. -- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. +- **Out of scope, unchanged**: persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). Sandbox escalation shipped later with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the pwsh tool now carries the sandbox denial rendering and the same-turn `sandbox_permissions` escalation surface, plus the Windows ConstrainedLanguage contract in its description. The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. ## Alternatives considered @@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Consequences -- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index d7a68f0cab..5c9dc70f25 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,其模型可见文本精确描述这一行为: - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 - **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 -- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 +- **范围外,不变**:持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。sandbox 升级随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 稍后交付——pwsh 工具现在携带 sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面,外加其描述中的 Windows ConstrainedLanguage 契约。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 ## 备选方案 @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- bash 与 pwsh 工具在前台、后台与沙箱化 shell 工作上行为可互换(sandbox 面随 Windows ACL sandbox 决策到来),pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a4ac5b09ad..411253cfd9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1138,7 +1138,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) -Source: [`packages/bash/pwsh-sandbox/src/index.ts:39`](../packages/bash/pwsh-sandbox/src/index.ts) +Source: [`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -2057,7 +2057,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:47`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/module-graph.md b/docs/module-graph.md index 5a0447e62e..477399d72d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -988,6 +988,7 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1342,7 +1343,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 42b68bb6e8..02170c580e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -212,7 +212,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `pwsh` -Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. 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`. +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env: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. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. 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`. ```json { diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index 38e44a431a..7a930b7f0e 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -6,8 +6,9 @@ * enforcement, and denial facts. Positive runner-launch evidence means the * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while * background processes carry `runnerFailed`; other spawn rejections retain - * local-executor semantics. The tool owns approval and passes a complete - * per-call policy. + * local-executor semantics. The tool layer owns the escalation approval flow + * through `ctx.approval`; this executor reports the sandbox facts the tool + * renders. * @module @deepseek-ai/dsh-pwsh-sandbox */ @@ -40,10 +41,12 @@ export type Config = LocalConfig /** * Registers as `ctx.bash` in place of the local pwsh executor and requires a - * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is - * unchanged. Tool calls pass the calling session's resolved policy; direct - * calls fall back to deployment policy. `result.sandbox` reports the mode and - * enforcement actually used. + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the + * sandbox denial rendering and escalation surface (see the + * pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's + * resolved policy; direct calls fall back to deployment policy. + * `result.sandbox` reports the mode, enforcement, and denial facts the tool + * renders. */ /* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */ export class SandboxPwshExecutor extends PwshLocalExecutor { diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 39325f5987..fa9af8edce 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 78eb161f77b9524bc577b273abe59db6b931727c -README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210 +README.md: c0c253fe24bd7eaa4f4d814141e17a67f10f4fb8 +README.zh.md: 7e672bfb4fbda8f683cab3dbb018624d43b2a6d2 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 78eb161f77..c0c253fe24 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker). Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). @@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero | `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | | `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | | `run_in_background` | boolean | Return a task id immediately; no timeout applies. | +| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. | +| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. | `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. @@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. -Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. -The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. @@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict #### What the model sees -The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. #### Token effect @@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. #### Token effect @@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **ConstrainedLanguage under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The tool description teaches this contract to the model; the backend README owns the full limitation. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. -- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 17696fe6d9..7e672bfb4f 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 @@ -21,6 +21,8 @@ | `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | | `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | | `run_in_background` | boolean | 立即返回 task id;不适用超时。 | +| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed,不运行任何内容。 | +| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 | `command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 @@ -28,9 +30,9 @@ 每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 -结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 -规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 @@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### What the model sees -渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[sandbox: file access denied under mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 #### Token effect @@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。 #### What the model sees -校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 #### Token effect @@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **Windows sandbox 下的 ConstrainedLanguage** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。工具描述把这一契约教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 625ae29c30..042166b493 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index e557e56248..e93b246469 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,15 +4,17 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the escalation - * surface: foreground and `run_in_background` execution (background handles - * register with the generic `ctx.tasks` runtime), the managed `DSH_*` - * environment through the shared `bash-env` registry, the per-call sandbox - * policy resolution (the calling session's mode and cwd travel to the - * confining executor), and the bash marker/truncation rendering story. UI - * presentation mirrors the bash tool's too: a completed - * foreground call is a terminal card with the parsed exit-status pill, using - * the shared exit-status parse from `@deepseek-ai/dsh-bash`. + * Behavior mirrors `dsh-tool-bash` call-for-call: foreground and + * `run_in_background` execution (background handles register with the + * generic `ctx.tasks` runtime), the managed `DSH_*` environment through the + * shared `bash-env` registry, the per-call sandbox policy resolution (the + * calling session's mode and cwd travel to the confining executor), the + * sandbox-denial rendering with the same-turn escalation surface + * (`sandbox_permissions` + `justification` resolved through + * `ctx.approval`), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed foreground call is + * a terminal card with the parsed exit-status pill, using the shared + * exit-status parse from `@deepseek-ai/dsh-bash`. * * @module @deepseek-ai/dsh-tool-pwsh */ @@ -27,12 +29,15 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' -import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-user-approval' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' +import type { RenderablePwshResult } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -61,6 +66,8 @@ interface PwshToolArgs { timeoutMs?: number workdir?: string run_in_background?: boolean + sandbox_permissions?: string + justification?: string } /** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ @@ -73,6 +80,7 @@ interface PwshForegroundResult { timeoutMs: number stdout: { text: string; truncated: boolean; spillPath?: string } stderr: { text: string; truncated: boolean; spillPath?: string } + sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean } } /* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ @@ -86,21 +94,41 @@ function validatePwshArgs(args: PwshToolArgs): void { if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } + // The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is + // the shared rule both enforcing families validate identically. + validateEscalationArgs(args.sandbox_permissions, args.justification) } /* jscpd:ignore-end */ -function pwshDescription(backgroundEnabled: boolean): string { +function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string { const background = backgroundEnabled ? '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`.' : 'Background execution is not available; long-running commands must finish within the timeout.' - return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + 'Current harness environment facts are exposed through managed `$env: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. ' + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background + if (escalationModes.length === 0) return base + return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + + '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.' } /** @@ -133,6 +161,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ stdout: output(result.stdout), stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, } } @@ -143,17 +179,56 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const /* jscpd:ignore-end */ +/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode + const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') if (defaultMode !== undefined && sandboxPolicy === undefined) { throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') } + /* jscpd:ignore-end */ /** Resolve the complete standing policy for this call when a confining executor is mounted. */ const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */ + /** + * Resolve a sandbox-escalation request through `ctx.approval` BEFORE + * anything executes, delegating the shared fail-closed sequence (strict + * widening, channel resolution, outcome mapping) to + * {@link approveEscalation}. This tool contributes only the composition + * guard (the fields are unadvertised without a sandboxing executor, yet + * schema validation checks advertised keys only, so an unadvertised + * `sandbox_permissions` still reaches execute) and the approval + * ingredients. The shared policy resolver is required whenever the + * executor advertises confinement, so a split composition fails at + * tool-plugin load. + */ + const approvePwshEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise => { + if (escalationModes.length === 0) { + throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + } + const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode + return approveEscalation( + { requestedMode: mode, justification, effectiveMode, subject: 'command' }, + { + approver: ctx.get('approval'), + agent: exec.agent, + callId: exec.callId, + toolName: 'pwsh', + signal: exec.signal, + }, + ) + } + /* jscpd:ignore-end */ + ctx.systemPrompt.section({ name: 'tool:pwsh', order: 105, @@ -163,7 +238,8 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.tools.register(defineTool({ name: 'pwsh', - description: pwshDescription(backgroundEnabled), + description: pwshDescription(backgroundEnabled, escalationModes), + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */ parameters: { command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, description: { @@ -178,7 +254,19 @@ export function apply(ctx: Context, config: Config = {}): void { ...backgroundEnabled ? { run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, } : {}, + ...escalationModes.length > 0 ? { + sandbox_permissions: { + type: 'string' as const, + enum: [...escalationModes], + 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.', + }, + justification: { + type: 'string' as const, + description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.', + }, + } : {}, }, + /* jscpd:ignore-end */ output: { // The foreground result wire shape mirrors dsh-tool-bash's by contract — // consumers of one must accept the other (see the pwsh-tool-and-executor @@ -221,6 +309,16 @@ export function apply(ctx: Context, config: Config = {}): void { spillPath: { type: 'string' }, }, }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, }, }, ], @@ -230,7 +328,7 @@ export function apply(ctx: Context, config: Config = {}): void { type: 'text', text: value.kind === 'background' ? `started background task ${value.taskId}` - : renderPwshResult(value), + : renderPwshResult(value as RenderablePwshResult, escalationModes), }], }, /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ @@ -238,13 +336,19 @@ export function apply(ctx: Context, config: Config = {}): void { validatePwshArgs(args) // Description is display metadata; workdir defaults to the caller's session. const standingPolicy = resolveSandboxPolicy(exec) + const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) + : undefined + const policy = approvedMode === undefined + ? standingPolicy + : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), - ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {}, + ...policy !== undefined ? { sandboxPolicy: policy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. @@ -256,15 +360,11 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // The caller owns cancellation until ctx.tasks commits detached ownership. - /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; - pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts - already-aborted signals first, so this mirror-only guard has no reachable trigger. */ if (exec.signal.aborted) { const error = new HarnessError('tool call aborted', TOOL_ABORTED) error.name = 'AbortError' throw error } - /* v8 ignore end */ // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'pwsh', @@ -275,7 +375,7 @@ export function apply(ctx: Context, config: Config = {}): void { return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), - readOutput: () => renderPwshProcessRead(proc.readOutput()), + readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts index 42f4bc696c..52616a8e68 100644 --- a/packages/bash/tool-pwsh/src/render.ts +++ b/packages/bash/tool-pwsh/src/render.ts @@ -1,17 +1,20 @@ /** * Model-facing result rendering for the pwsh tool — the PowerShell twin of - * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked - * stderr section, truncation notices with spill paths, then exit-status - * markers. Non-zero exits are reported, not errored — the model decides how to - * react; only infrastructure failures (spawn errors, aborts) surface as - * isError results. + * `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox + * denial/runner-failure markers (with the same-turn escalation hint), and + * truncation notices with spill paths, then exit-status markers. Non-zero + * exits are reported, not errored — the model decides how to react; only + * infrastructure failures (spawn errors, aborts) surface as isError + * results. * * @module @deepseek-ai/dsh-tool-pwsh/render */ -import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' -/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */ /** Append the truncation notice (with the full-output spill path) to a stream's text. */ function streamText(output: CollectedOutput): string { @@ -27,6 +30,7 @@ export interface RenderablePwshResult { timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput + sandbox?: BashSandboxInfo } /** @@ -34,9 +38,15 @@ export interface RenderablePwshResult { * stderr section, then exit-status markers, matching the bash tool's story — * a clean exit (0, no signal) produces no marker. * @param result - the completed foreground run from the executor. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. */ -export function renderPwshResult(result: RenderablePwshResult): string { +export function renderPwshResult( + result: RenderablePwshResult, + escalationModes: readonly SandboxMode[] = [], +): string { const out = streamText(result.stdout) const err = streamText(result.stderr) @@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string { if (body.length === 0) body = '(no output)' const markers: string[] = [] + // Keep the exit marker last because parseExitStatus anchors there. + if (result.sandbox?.denied) { + markers.push(sandboxDenialMarker(result.sandbox.mode)) + // Hint only when the composition exposes escalation, before the final exit marker. + if (escalationModes.length > 0) { + markers.push(escalationHintMarker('command')) + } + } // A command may trap the termination and exit 0 after timeout; still report interruption. if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) if (result.signal !== null) { @@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string { * sees: the incremental delta, plus the lossy-read notice (with full-stream * spill paths) when in-memory truncation dropped unread bytes. * @param read - one incremental read from the process handle. - * @returns the delta text with any loss notice appended. + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the delta text with any loss or sandbox notice appended. */ -export function renderPwshProcessRead(read: BashProcessRead): string { +export function renderPwshProcessRead( + read: BashProcessRead, + sandbox?: BashSandboxInfo, + escalationModes: readonly SandboxMode[] = [], +): string { const notices: string[] = [] if (read.lossy) { const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) } + if (sandbox?.runnerFailed) { + notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) + } else if (sandbox?.denied) { + notices.push(sandboxDenialMarker(sandbox.mode)) + if (escalationModes.length > 0) { + notices.push(escalationHintMarker('command')) + } + } if (notices.length === 0) return read.delta return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` } diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 454c244c69..1464ca14d3 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -5,11 +5,12 @@ * text, truncation, timeout, abort, nonzero exits, background handles — so * these tests verify the schema, argument validation, workdir derivation, * managed `DSH_*` collection, abort translation, canonical result projection, - * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * sandbox denial rendering with the escalation surface, rendering, + * background task wiring, and the UI presenters. Real-pwsh behavior * is pinned separately in integration.spec.ts. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -22,6 +23,8 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -155,9 +158,12 @@ async function setupWithTasks(toolConfig: Partial = {}, dshHome * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve * the calling session's standing policy and stamp it on the request, exactly * like the bash tool — the per-session sandbox-policy regression surface. + * Records each confined mode and returns scriptable sandbox facts so the + * escalation and rendering surfaces are testable without a real backend. */ class ConfiningFakeBash extends BashExecutor { requests: BashExecRequest[] = [] + modes: Array = [] override get sandboxMode() { return 'read-only' as const @@ -176,29 +182,73 @@ class ConfiningFakeBash extends BashExecutor { } } - override async run(_spec: BashExecSpec): Promise { - return runResult('ok\n') + override async run(spec: BashExecSpec): Promise { + this.modes.push(spec.sandboxPolicy?.mode) + return runResult('ok\n', { + sandbox: { + mode: spec.sandboxPolicy?.mode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, + }) } - override start(_spec: BashExecSpec): BashProcess { + override start(spec: BashExecSpec): BashProcess { + this.modes.push(spec.sandboxPolicy?.mode) return fakeProcess() } } -/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool. */ -async function setupSandboxed(toolConfig: Partial = {}) { +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */ +async function setupSandboxed(withApproval = false) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) - await ctx.plugin(ToolPwsh, toolConfig) + if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(ToolPwsh) const bash = ctx.bash as ConfiningFakeBash return { ctx, bash } } +/** + * Build a fake {@link Agent} whose session log carries the sandbox-policy + * mode-override event the escalation flow evaluates against, with an + * appendable log (the approval service records decisions through + * `session.append`). + */ +function sandboxAgent( + mode?: 'read-only' | 'workspace-write' | 'danger-full-access', + ctx?: Context, + onAppend?: (type: string) => void, +): Agent { + const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const id = SessionId('sandbox-session') + return { + id, + ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, + session: { + id, + header: { version: 0, id, createdAt: 0 }, + events, + append: (type: string, data: Record) => { + const event = { type, data } + events.push(event) + onAppend?.(type) + return event + }, + }, + } as unknown as Agent +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. @@ -494,6 +544,154 @@ describe('per-call sandbox policy resolution', () => { }) }) +describe('sandbox escalation through ctx.approval', () => { + const escalate = { + command: 'Write-Output ok', + description: 'test escalation', + sandbox_permissions: 'workspace-write', + justification: 'the command needs workspace writes', + } + + it('advertises the sandbox fields, the escalation clause, and the ConstrainedLanguage contract', async () => { + const { ctx } = await setupSandboxed() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + const properties = schema.parameters.properties as Record + expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) + expect(schema.description).toContain('approval prompt') + expect(schema.description).toContain('ConstrainedLanguage') + + for (const args of [ + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' }, + { command: 'Write-Output ok', description: 'd', justification: 'why' }, + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }, + ]) { + expect((await call(ctx, 'pwsh', args)).isError).toBe(true) + } + }) + + it('the escalation fields and the ConstrainedLanguage clause stay out of sandbox-less compositions', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + expect(schema.description).not.toContain('ConstrainedLanguage') + expect(schema.description).not.toContain('sandbox_permissions') + expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') + }) + + it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => { + const plain = await setup() + expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') + + const { ctx } = await setupSandboxed(true) + const prompted = vi.fn() + ctx.on('approval/request', () => { prompted(); return Promise.resolve('allowed-once') }) + const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write')) + expect(text(result)).toContain('not strictly wider') + expect(prompted).not.toHaveBeenCalled() + + const malformed = sandboxAgent() + ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + type: 'sandbox/mode', + data: { mode: 'unknown-mode' }, + }) + expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') + }) + + it('fails closed when approval cannot be routed', async () => { + const withoutService = await setupSandboxed() + expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service') + + const withService = await setupSandboxed(true) + expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route') + expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel') + }) + + it.each([ + ['rejected', 'user rejected'], + ['cancelled', 'was cancelled'], + ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve(outcome)) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain(message) + expect(bash.modes).toEqual([]) + }) + + it('runs a granted foreground or background call under the approved mode', async () => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const agent = sandboxAgent(undefined, ctx) + ctx.agents.register(agent) + const foreground = await ctx.tools.execute({ + callId: CallId('sandbox-signal'), + name: 'pwsh', + arguments: escalate, + agent, + signal: new AbortController().signal, + }) + expect(foreground.isError).toBe(false) + const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent) + expect(text(background)).toBe('started background task pwsh-1') + expect(bash.modes).toEqual(['workspace-write', 'workspace-write']) + }) + + it('does not publish detached work when cancellation follows the escalation grant', async () => { + const { ctx, bash } = await setupSandboxed(true) + const controller = new AbortController() + const agent = sandboxAgent(undefined, ctx, (type) => { + if (type === 'approval/decided') controller.abort() + }) + ctx.agents.register(agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const start = vi.spyOn(bash, 'start') + + const result = await ctx.tools.execute({ + callId: CallId('cancelled-escalation-background'), + name: 'pwsh', + arguments: { ...escalate, run_in_background: true }, + agent, + signal: controller.signal, + }) + + expect(result.error).toEqual({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) + expect(text(result)).toBe('Error: tool call aborted') + expect(start).not.toHaveBeenCalled() + }) + + it('uses the session override for ordinary calls and evaluates widening against it', async () => { + const { ctx, bash } = await setupSandboxed(true) + const agent = sandboxAgent('workspace-write') + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent) + expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'pwsh', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + if (result.isError) throw new Error('expected foreground pwsh success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { + const { ctx } = await setupSandboxed(true) + ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain('unreachable variant in EscalationOutcome') + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() @@ -738,6 +936,35 @@ describe('UI presentation', () => { }) }) +describe('renderPwshResult sandbox markers', () => { + const base = { + exitCode: 0, + signal: null, + timedOut: false, + timeoutMs: 1000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: '', truncated: false }, + } + + it('a denied run reports the denial marker before the exit marker', () => { + expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } })) + .toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]') + }) + + it('hints only when the composition advertises escalation', () => { + const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } } + expect(renderPwshResult(denied, ['workspace-write'])).toBe( + 'out\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]', + ) + }) + + it('a confined run without a denial adds no sandbox marker', () => { + expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n') + }) +}) + describe('renderPwshProcessRead', () => { const base: BashProcessRead = { delta: 'out\n', lossy: false } @@ -774,6 +1001,20 @@ describe('renderPwshProcessRead', () => { expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') }) + + it('appends the runner-failed notice (denial outranked)', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true })) + .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]') + }) + + it('appends the denial marker and hints only when escalation is advertised', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true })) + .toBe('x\n[sandbox: file access denied under read-only mode]') + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write'])) + .toBe('x\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + }) }) describe('processOutcome', () => { diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 2d383d22fa..bd874b34c7 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../ui/user-approval" + }, { "path": "../../sandbox/sandbox" }, From 9d10a1888b0196a68baf84686ae754460c51b6b7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:24:19 +0800 Subject: [PATCH 36/62] fix(pwsh): record the tool-pwsh user-approval dependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de3d7f8b5f..5bdb9cf308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -994,6 +994,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 441927c5268e1abd2be024d3b5bb69e3869b6f06 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 21:55:02 +0800 Subject: [PATCH 37/62] =?UTF-8?q?fix(sandbox):=20drop=20Authenticated=20Us?= =?UTF-8?q?ers=20from=20both=20restricting=20lists=20=E2=80=94=20close=20t?= =?UTF-8?q?he=20C:\\-root=20escape,=20CIM=20unavailable=20in=20every=20con?= =?UTF-8?q?fined=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace-write now runs [logon SID, Everyone, orphan]: the two lists differ only by the orphan, and the keep-alive group (logon SID + Everyone) is the single shared invariant. The WMI namespace security check fails in BOTH modes (0x80041003), so CIM/Get-ComputerInfo are unavailable everywhere — the price of closing the C:\\-root tree-creation escape (AU:(AD) + AU:(OI)(CI)(IO)(M)) in workspace-write too. The unused WinLocalSid/WinInteractiveSid/WinAuthenticatedUserSid constants and their ABI-probe prints are removed; the enforcement 'full' claim now stands on a closed NTFS surface. New regression: a C:\\Users\\Public subdirectory write is denied under BOTH modes (the ambient-writable blind spot the review flagged — INTERACTIVE is absent from both lists). FAT-class (non-ACL) targets outside the granted roots remain writable (no security descriptors to intersect) — documented as a legacy residue, warn-only, not engineered around. Docs/design note/PR body updated in both languages (list I/J terminology gone everywhere). --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox-local/tests/acl-session.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 10 ++-- .../sandbox/sandbox-windows-acl/README.zh.md | 10 ++-- .../sandbox/sandbox-windows-acl/src/index.ts | 7 +-- .../sandbox/sandbox-windows-acl/src/runner.ts | 9 ++- .../sandbox/sandbox-windows-acl/src/token.ts | 53 +++++++++--------- .../sandbox-windows-acl/src/win32-abi.ts | 14 +---- .../sandbox-windows-acl/tests/runner.spec.ts | 55 ++++++++++++++++--- .../sandbox-windows-acl/verify/abi-probe.cpp | 3 - 14 files changed, 104 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 3b28349045..8b534a3267 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 4459e121c09fe566efc9a35604df10dae223928c -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821 +2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 4459e121c0..62b9613adf 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID + Everyone only — no orphan and no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed, and a standing grant ACE from an earlier workspace-write period stays INERT: the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free) and list J (`workspace-write` = + Authenticated Users + orphan, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — and the ConstrainedLanguage pins in both modes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 307f39d366..95c2604d6a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 仅登录 SID + Everyone——不含孤儿 SID 与 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭,且先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**:pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)与 list J(`workspace-write` = + Authenticated Users + 孤儿 SID,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 ## Related diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index fb1a76cc9c..93b92de0a0 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -381,7 +381,7 @@ export class LocalSandboxProvider extends SandboxProvider { * NOTHING — its token alone restricts every write, and a standing grant * from an earlier workspace-write period is KEPT through a downgrade * (never revoked): the read-only restricted token carries no orphan SID - * (list I), so the ACE is inert there, while the map hit keeps the + * (the read-only list), so the ACE is inert there, while the map hit keeps the * re-upgrade free of re-propagation. Fail-closed: a half-materialized * grant is revoked before the error propagates. * @param record - the session's durable record. diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 7ebb5ff2ed..d1cca979a0 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -199,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(1) // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list I carries no + // re-granted. The read-only restricted token's list carries no // orphan SID (pinned by the windows-acl runner regression), so the // ACE is inert under read-only while the map hit keeps the // re-upgrade free of eager propagation. diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 80a35821d4..834cebbe3f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 2e3a16fa84541ffb2eb442c953777f37407e1b46 -README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3 +README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 +README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 2e3a16fa84..e9309caa87 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -40,9 +40,11 @@ The runner creates the restricted token, spawns the wrapped argv under it with t **Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. -Modes (the token's restricting-SID list follows the mode): -- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. -- `read-only` (list I = logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The orphan stays OUT of list I on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. +Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): +- `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable. The orphan stays OUT of the list on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). + +Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. @@ -81,5 +83,5 @@ None directly; the denial surface belongs to the tool layer. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. +- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index e58b70a120..eabf8f1c1e 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -40,9 +40,11 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接 **按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -模式(令牌的 restricting SID 列表随模式而定): -- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 -- `read-only`(列表 I = 登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。孤儿 SID 有意留在列表 I **之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 +模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置。孤儿 SID 有意留在列表**之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 + +Authenticated Users 在**两种**列表中都缺席——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)在**每一种**受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——模型可见面文档化的是这一契约,而非提示词承诺。INTERACTIVE/LOCAL 同样在**两种**列表中都缺席:宿主的 Public 树把写权限授予 INTERACTIVE,因此 Public 写入会被拒绝——由 runner 的环境可写 Public-probe 回归钉住(见设计笔记)。 `AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 @@ -81,5 +83,5 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 -- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 +- **宽目录与 FAT 卷警告留待后续;FAT 类目标保持可写。** 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**时只会让授权立即报错(无 ACL 支持)。位于授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查会通过(Everyone 在两种列表中都存在),这类目标在**两种**受限模式下都可写。FAT 视作历史残留——不支持、不工程化应对;这一仅警告性姿态在此记录成文,而非加以缓解。 - **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 07f35ed177..f22dadfbb0 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -195,14 +195,9 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid) - this.sidAllocations.push(authUserSid) const restricted = createRestrictedToken( api, currentToken, logonSid, writeSidPtr, - { - world: worldSid, - authUser: authUserSid, - }, + { world: worldSid }, this.mode, ) this.token = restricted diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index a6be67fc3f..3487e182be 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -15,9 +15,12 @@ * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); the token's restricting - * list also drops Authenticated Users (CIM unavailable — documented in - * README). + * NUL device (`> $null` fails with access denied); the restricting list + * carries no orphan SID, so a standing grant ACE from an earlier + * workspace-write period stays inert. BOTH modes drop Authenticated Users + * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the + * Public tree writes are denied); the two lists share the keep-alive group + * (logon SID, EVERYONE) and differ only by the orphan. * * `--write-sid`: the seam's per-session grant contract — the CALLER has * already materialized the orphan-SID ACEs (once per session, server diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 2b114a6567..bd0f71ee9f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -101,44 +101,41 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } -/** The well-known SIDs packed into every restricted token's restricting list. */ +/** The well-known SID packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr - authUser: NativePtr } /** * Create the write-restricted token with the mode-selected restricting list - * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant - * harness): - * - list I (read-only): [logon SID, EVERYONE] - * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * (verified on Win11 26200, see the POC-worktree restrict-variant harness): + * - read-only: [logon SID, EVERYONE] + * - workspace-write: [logon SID, EVERYONE, orphan] * - * The logon SID and EVERYONE are shared: they keep the early startup chain - * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh - * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in - * list J ONLY because the CIM path's WMI namespace security check requires it - * (0x80041003 otherwise) — read-only drops it for a zero ambient-write - * surface (it closes the host's C:\-root tree-creation escape, where - * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM - * unavailability; documented in README. List I also carries NO orphan: a - * standing grant ACE from an earlier workspace-write period (a - * `/permission` mode downgrade, or a crash-resumed session) must stay INERT - * under read-only — the WRITE_RESTRICTED pass-2 check grants only what the - * restricting list carries, so omitting the orphan keeps read-only strictly - * zero-grant even with stale ACEs standing, while the unrevoked ACE keeps - * the re-upgrade free (the seam's grant map hits it — no re-propagation). - * INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree - * grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1 - * (console logon) is intentionally absent: see win32-abi.ts for the - * verified failure modes. FAILS CLOSED: any failure throws — never spawn - * unrestricted. + * The logon SID + EVERYONE keep-alive group is shared by both modes: early + * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — + * pwsh crashes 0xE0434352) fails without them. The orphan SID joins ONLY + * workspace-write — read-only carries no orphan, so a standing grant ACE + * from an earlier workspace-write period (a `/permission` mode downgrade, or + * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED + * pass-2 check grants only what the restricting list carries, keeping + * read-only strictly zero-grant even with stale ACEs standing, while the + * unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no + * re-propagation). Authenticated Users is absent from BOTH lists: the WMI + * namespace security check fails (0x80041003), so CIM is unavailable in + * every confined mode, and the C:\-root tree-creation escape (standing + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in + * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's + * Public tree grants write to INTERACTIVE, so removing it closes that + * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts + * for the verified failure modes. FAILS CLOSED: any failure throws — never + * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist (list J only). + * @param writeSid - the orphan SID forming the write allowlist (workspace-write only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (I for read-only, J for workspace-write). + * @param mode - selects the restricting list (workspace-write adds the orphan). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -151,7 +148,7 @@ export function createRestrictedToken( ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, known.authUser, writeSid]) + : [logonSid, known.world, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index b96acf4942..cc250c2f9e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -79,20 +79,8 @@ export const LUA_TOKEN = 0x4 export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) -/** WinWorldSid: S-1-1-0 (Everyone). */ +/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ export const WinWorldSid = 1 -/** - * WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init - * (it sits in every restricted token's restricting list). The - * CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this - * module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to - * this type. - */ -export const WinLocalSid = 2 -/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ -export const WinInteractiveSid = 11 -/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ -export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) /** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 18af8a8ffd..2ce251c308 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -38,6 +38,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let isolatedTemp!: string let secretFile!: string let escapeFile!: string + // The ambient-writable probe target: a subdirectory of C:\Users\Public. + // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public + // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient + // boundary the dual-list design closes (bot-reported blind spot). The + // Public tree may be unavailable or unwritable for the test user on some + // hosts; the probe test skips itself when the directory cannot be created. + let publicProbeDir: string | undefined beforeAll(() => { scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) @@ -47,11 +54,17 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') + try { + publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) + } catch { + publicProbeDir = undefined + } }) afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) rmSync(isolatedTemp, { recursive: true, force: true }) + if (publicProbeDir !== undefined) rmSync(publicProbeDir, { recursive: true, force: true }) }) it('workspace-write: the confined child writes granted directories only', () => { @@ -65,8 +78,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List J carries Authenticated Users: the CIM path (WMI namespace - // security check) stays alive under workspace-write. + // Authenticated Users is absent from BOTH lists: the WMI namespace + // security check fails (0x80041003) — CIM is unavailable under every + // confined mode (the documented contract; the C:\-root tree-creation + // escape is closed in both as the other side of the trade). "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -79,12 +94,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') - expect(result.stdout).toContain('CIM: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -95,9 +110,9 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List I drops Authenticated Users: the WMI namespace security check - // fails (0x80041003) — the documented read-only CIM boundary, the - // price of the zero ambient-write surface. + // BOTH lists drop Authenticated Users: the WMI namespace security + // check fails (0x80041003) — the documented CIM boundary of every + // confined mode, the price of the zero ambient-write surface. "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -177,7 +192,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After - // switching to read-only, the restricted token's list I must carry NO + // switching to read-only, the restricted token's read-only list must carry NO // orphan SID — the standing ACE stays but the pass-2 check cannot use // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands @@ -214,6 +229,30 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('ambient-writable escape regression: a C:\\Users\\Public subdirectory is denied under BOTH modes (INTERACTIVE absent from both lists)', (ctx) => { + // The Public tree grants write to INTERACTIVE; the D1-D6 matrix pinned + // that removing INTERACTIVE from the restricting lists closes the escape. + // The committed suites never probed it — this pins the ambient boundary + // end to end with the real restricted token. + if (publicProbeDir === undefined) { + ctx.skip() // Public unavailable/unwritable on this host + return + } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${publicProbeDir}\\public-escaped.txt' -Value ok -ErrorAction Stop;'PUBLIC-WRITE: OK (ESCAPE!)'}catch{'PUBLIC-WRITE: DENIED'}`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('PUBLIC-WRITE: DENIED') + expect(existsSync(join(publicProbeDir, 'public-escaped.txt')), `mode: ${mode}`).toBe(false) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 866fda7160..a74afe9d80 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -109,9 +109,6 @@ int wmain() P(WRITE_RESTRICTED); P((int)WinWorldSid); - P((int)WinLocalSid); - P((int)WinInteractiveSid); - P((int)WinAuthenticatedUserSid); P((int)WinLocalLogonSid); P((int)WinConsoleLogonSid); From 6478da61e3cceccc626ccf00832b8d64bc387c9b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 23:23:38 +0800 Subject: [PATCH 38/62] fix(sandbox): harden the per-session record and the ACL runner failure paths (review round v6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 6 +- docs/core-data-structures/sandbox.zh.md | 6 +- docs/module-graph.md | 131 ++++---- docs/persistence-catalog.md | 10 +- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 7 + packages/bash/tool-pwsh/src/index.ts | 6 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../sandbox/sandbox-local/src/acl-session.ts | 92 ++++-- packages/sandbox/sandbox-local/src/index.ts | 94 ++++-- .../sandbox-local/tests/acl-session.spec.ts | 294 +++++++++++------- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 7 +- .../sandbox/sandbox-windows-acl/README.zh.md | 7 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 4 + .../sandbox/sandbox-windows-acl/src/index.ts | 10 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 4 + .../tests/failure-paths.spec.ts | 26 ++ .../tests/provider-chain.spec.ts | 4 +- packages/sandbox/sandbox/package.json | 2 + packages/sandbox/sandbox/src/index.ts | 7 +- packages/sandbox/sandbox/tsconfig.json | 3 + 27 files changed, 472 insertions(+), 278 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 8b534a3267..c9e3414e4d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 +2026-08-08-windows-acl-restricted-token-sandbox.md: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 62b9613adf..6c358c1bdc 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 95c2604d6a..028b7dedf5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index abebea09b5..ca1ea8fcaa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:156`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index dd31511e20..a759b15b74 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/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 docs/core-data-structures/sandbox.md -sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da -sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c +sandbox.md: 75e9a0a9b1eb85a5c097f2c583a1fdf94da62305 +sandbox.zh.md: df2dae84f43d5e64bb11418012bdcbd196f028f2 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index a9a1fec080..75e9a0a9b1 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 99505265a9..df2dae84f4 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 477399d72d..5194a1f5b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -413,8 +413,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -425,13 +423,6 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_agent --> pkg_type_meta - pkg_bash --> pkg_invariants - pkg_bash --> pkg_sandbox - pkg_bash --> pkg_subprocess - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants @@ -500,10 +491,9 @@ flowchart TD pkg_lsp_local --> pkg_lsp pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox - pkg_sandbox_local --> pkg_session + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -524,22 +514,13 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_type_meta - pkg_bash_local --> pkg_bash - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -548,9 +529,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -597,10 +575,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -614,6 +588,10 @@ flowchart TD pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -660,21 +638,22 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_bash - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants @@ -683,6 +662,9 @@ flowchart TD pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> pkg_session pkg_compact_tool_result_prune --> pkg_token_meter + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -720,6 +702,10 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -758,6 +744,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1230,11 +1231,8 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1252,19 +1250,15 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1276,10 +1270,10 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1290,17 +1284,21 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1308,6 +1306,9 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9b396d98b8..d64524c675 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -501,13 +501,15 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -515,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 9e4aa04546..d710801004 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -129,6 +129,13 @@ describe('helpers (pure)', () => { expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() }) + + it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => { + const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }] + expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules)) + .toEqual({ detail: 'windows-acl-run: missing --workspace' }) + }) }) describe('matchesSignature', () => { diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index e93b246469..fb07683c96 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,6 +114,12 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base + // The CLM contract below is Windows-restricted-token behavior, but the gate + // is 'any confining executor is mounted' (escalationModes non-empty). The + // conflation is safe today because every shipped composition pairing + // tool-pwsh with a confining executor is win32-only; a future POSIX + // pwsh-sandbox composition must gate the CLM sentence on the platform + // instead (tracked in the pwsh-tool-and-executor Agent Note). return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 286020dfcd..62f85bab96 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: SessionId;\n}', }, { name: 'SandboxMode', diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index c820694b11..5247601250 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -8,18 +8,20 @@ * ({@link AclWriteGrant} materialization, revoked on dispose); the record * survives restarts so a resumed session reuses the SAME SID — re-granting * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a - * fresh record; the record's workspace must match the session's immutable - * cwd (asserted by the provider). + * fresh dead SID's ACEs per restart. The record is BOUND to its owning + * session id, so a fork (which copies the parent's events, record included) + * never inherits the parent's identity — it provisions a fresh one. The + * record's payload is durable input and is validated at the fold (orphan-SID + * shape, well-formed temp path); a matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ -import { createHash } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -27,13 +29,15 @@ declare module '@deepseek-ai/dsh-session' { * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -46,48 +50,73 @@ declare module '@deepseek-ai/dsh-session' { export interface AclSessionRecord { /** The orphan write SID whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session id (binds the record against fork inheritance). */ + sessionId: SessionId /** The workspace root the record was provisioned for. */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } +/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ +const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u + /** - * The session's windows-acl record: the last `sandbox/acl-session` event in - * the log, or undefined when the session has none (never confined on - * Windows). 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 last provisioned record, or undefined without one. + * The session's record: the last `sandbox/acl-session` event owned by it, or + * undefined (never confined / a fork). Durable-input validation: tampered + * SID or temp path fails loud. @param events/@param sessionId/@returns as + * below. + * @param events - session events (other types skipped). + * @param sessionId - owning session (fork binding). + * @returns the last owned record, or undefined without one. */ -export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { +export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] as SessionEvent - if (event.type === 'sandbox/acl-session') return event.data + if (event.type !== 'sandbox/acl-session') continue + const data = event.data + // Fork copies the parent's record: skip non-owned records (fork mints fresh). + if (data.sessionId !== sessionId) continue + if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` + + '(expected the orphan shape S-1-4-x-y)', + ) + } + if (typeof data.workspace !== 'string' || data.workspace.length === 0) { + throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) + } + if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`, + ) + } + return data } return undefined } /** - * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges - * across server restarts (the same SID re-grants the same directory) and OS - * temp hygiene may reclaim it — deliberately no GC here. - * @param sessionId - the session identity. + * The session's private temp subdirectory name: `\dsh-<16 random hex>`. + * The name is RANDOM and persisted in the record — convergence across server + * restarts comes from the record (the same SID re-grants the same directory), + * not from any derivation an attacker (who knows the session id through + * `DSH_SESSION_ID`) could predict and pre-place. The provider creates it + * exclusively and rejects reparse points; OS temp hygiene may reclaim it — + * deliberately no GC here. * @returns the private temp subdirectory path. */ -export function sessionTempDir(sessionId: string): string { - const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) - return join(tmpdir(), `dsh-${digest}`) +export function sessionTempDir(): string { + return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`) } /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp - * subdirectory, appended as exactly one log-only `sandbox/acl-session` - * event — the provision IS its event, nothing mutates record state out of - * band. Fork (new session id) provisions a fresh record; resume replays the - * stored one. + * confined execution): a fresh write SID plus the private temp subdirectory, + * appended as exactly one log-only `sandbox/acl-session` event — the + * provision IS its event, nothing mutates record state out of band. Fork + * (whose copied parent record is not its own) provisions a fresh record; + * resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. @@ -95,8 +124,9 @@ export function sessionTempDir(sessionId: string): string { export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { writeSid: randomWriteSid(), + sessionId: session.id, workspace: workspaceRoot, - tempDir: sessionTempDir(session.id), + tempDir: sessionTempDir(), } session.append('sandbox/acl-session', record) return record diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 93b92de0a0..e18d68449a 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -29,7 +29,7 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' @@ -163,8 +163,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // The restricted token intersects every write access by construction, so - // the ACL runner governs every promised file effect — full enforcement. + // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists + // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are + // absent from both — pinned by the runner's Public-probe and CIM-denial + // regressions). FAT-class (non-ACL) targets are declared unsupported + // (warn-only) in the backend README — outside the promise, not an + // exception to it. 'windows-acl': 'full', } @@ -194,13 +198,19 @@ const DENIAL_SIGNATURES = { runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record +/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */ +const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127 + /** * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. * The windows-acl runner prints `windows-acl-run: ` on every - * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * runner-side failure and exits 127 — the rule is exit-gated on that status + * so a confined command that merely PRINTS the signature (or a runner + * cleanup failure reported on a non-zero child exit) is never misclassified + * as "the command did not run". Keep the Landlock tuple aligned with the * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ @@ -212,7 +222,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], - 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], + 'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -319,48 +329,63 @@ export class LocalSandboxProvider extends SandboxProvider { * the session log (provisioned on first use), its ACEs materialized once * per server lifetime, and the runner receives `--write-sid` plus the * session's PRIVATE temp subdirectory — it grants nothing and revokes - * nothing. Agentless calls (no session) pass no SID: the runner - * self-manages per-call grants on the ambient temp root. + * nothing. A fresh provision kicks an IMMEDIATE persistence flush right + * after the append (no write-behind debounce delay), narrowing the + * crash-and-lose-record window to the flush latency itself — the residual + * is documented in the README (the spawn seams are synchronous, so no + * await barrier exists between record and ACEs). Agentless calls (no + * session) pass no SID: the runner self-manages per-call grants on the + * ambient temp root. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) - if (record !== undefined) this.materializeAclGrant(record, policy.mode) + if (sessionId === undefined) { + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + '--temp', tmpdir(), + '--mode', policy.mode, + ] + } + const record = this.aclSessionRecord(sessionId, policy.workspaceRoot) + this.materializeAclGrant(record, policy.mode) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, // Workspace-write sessions confine their temp writes to the PRIVATE // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // and agentless runs pass the ambient temp root — the runner validates - // it exists but grants nothing (or self-manages, agentless only). - '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + // runs pass the ambient temp root — the runner validates it exists + // but grants nothing. + '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), '--mode', policy.mode, - ...record === undefined ? [] : ['--write-sid', record.writeSid], + '--write-sid', record.writeSid, ] } /** * Fold (or provision) the calling session's durable windows-acl record. * The provision appends exactly one log-only `sandbox/acl-session` event - * to the session log; the record's workspace must equal the policy root — - * both derive from the session's immutable cwd, so a mismatch is a - * corrupted composition and fails loud. + * to the session log and kicks an immediate persistence flush (the + * write-behind coordinator's bounded window would otherwise delay the + * record's durability past its ACE materialization); the record's + * workspace must equal the policy root — both derive from the session's + * immutable cwd, so a mismatch is a corrupted composition and fails loud. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. * @returns the session's record. */ - private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord { const store = this.ctx.get('sessions') if (store === undefined) { throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') } - const session = store.get(SessionId(sessionId)) + const session = store.get(sessionId) if (session === undefined) { throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) } - const existing = sessionAclRecord(session.events) + const existing = sessionAclRecord(session.events, sessionId) if (existing !== undefined) { if (existing.workspace !== workspaceRoot) { throw new Error( @@ -370,20 +395,30 @@ export class LocalSandboxProvider extends SandboxProvider { } return existing } - return provisionAclSession(session, workspaceRoot) + const record = provisionAclSession(session, workspaceRoot) + // Immediate durability kick: the append is write-behind (bounded + // coordinator window); flush now so the record is durable as close to + // its ACE materialization as the synchronous confine seam allows. The + // residual window (a crash inside the flush latency) can strand inert + // orphan-SID ACEs — documented in the README. + void store.flush(session) + return record } /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory (created here); read-only materializes - * NOTHING — its token alone restricts every write, and a standing grant - * from an earlier workspace-write period is KEPT through a downgrade - * (never revoked): the read-only restricted token carries no orphan SID - * (the read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * grant is revoked before the error propagates. + * the private temp subdirectory — created here EXCLUSIVELY (the name is + * random and unguessable, a pre-existing entry throws EEXIST, and a + * reparse point is rejected, so the grant never lands on an + * attacker-placed object); read-only materializes NOTHING — its token + * alone restricts every write, and a standing grant from an earlier + * workspace-write period is KEPT through a downgrade (never revoked): the + * read-only restricted token carries no orphan SID (the read-only list), + * so the ACE is inert there, while the map hit keeps the re-upgrade free + * of re-propagation. Fail-closed: a half-materialized grant is revoked + * before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ @@ -391,7 +426,10 @@ export class LocalSandboxProvider extends SandboxProvider { if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return const grant = AclWriteGrant.create(record.writeSid) try { - mkdirSync(record.tempDir, { recursive: true }) + // Exclusive creation (no `recursive`): a pre-existing entry OR a + // reparse point both fail EEXIST — the grant never lands on a foreign + // object. + mkdirSync(record.tempDir) grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index d1cca979a0..a0e7a38390 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,22 +1,19 @@ /** - * The windows-acl per-session grant: the DURABLE record (session-log event - * fold/provision) plus the SERVER-LIFETIME ACE materialization - * ({@link AclWriteGrant}), exercised through the REAL - * LocalSandboxProvider.confine() with a real session store. The Win32 surface - * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), - * so these assertions run in every CI lane that runs sandbox-local's suites; - * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- - * acl's own tests on win32 hosts. + * windows-acl per-session grant: the DURABLE record (log-event fold/provision + * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization, through the REAL LocalSandboxProvider.confine() with a + * real session store. Win32 surface mocked at the package boundary; the + * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { sessionTempDir } from '../src/acl-session.ts' @@ -52,7 +49,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { }) /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -70,6 +67,11 @@ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) } +/** A well-shaped private temp path under the host temp root (never created). */ +function shapedTempPath(): string { + return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) +} + describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const scratch: string[] = [] @@ -83,38 +85,30 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-1') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(confined.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'pwsh', '/Command', 'x', - ]) + expect(confined.argv).toContain('--write-sid') + expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain('workspace-write') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir + scratch.push(tempDir) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + expect(existsSync(tempDir)).toBe(true) // created exclusively expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the SECOND confine is the map hit — no new grant, no new event. - const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(second.argv).toEqual(confined.argv) + // Reuse: the second confine is the map hit. + sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(mockState.grants).toHaveLength(1) expect(session.events).toHaveLength(1) - // Provider dispose revokes the standing grant. await fiber.dispose() expect(mockState.grants[0]!.disposed).toBe(true) } finally { @@ -122,15 +116,60 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + + // read-only first: record rides along, nothing materialized, ambient temp. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toContain('--write-sid') + expect(confinedRo.argv).toContain(tmpdir()) + expect(mockState.grants).toHaveLength(0) + const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string } + scratch.push(record.tempDir) + expect(existsSync(record.tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the same SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', record.tempDir, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + expect(existsSync(record.tempDir)).toBe(true) + + // Reuse: map hit. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(1) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-ro') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') } const confined = sandbox.confine(['true'], policy) expect(confined.argv).toEqual([ @@ -143,69 +182,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - } finally { - cleanup() - } - }) - - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const privateTemp = sessionTempDir('sess-switch') - scratch.push(privateTemp) - const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' } - - // Read-only first: the record still rides along (--write-sid, one - // event) but NOTHING is materialized and the ambient temp root is - // passed — the map stays empty, so the later upgrade must materialize. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tmpdir(), - '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) - - // Upgrade: the FIRST workspace-write confine materializes the grant - // (the map was empty — read-only never wrote it) with the SAME SID - // and the private temp dir, so the upgrade path cannot dead-end. - const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) - - // Reuse: the second workspace-write call is the map hit. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) - - // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list carries no - // orphan SID (pinned by the windows-acl runner regression), so the - // ACE is inert under read-only while the map hit keeps the - // re-upgrade free of eager propagation. - sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { cleanup() @@ -216,21 +192,20 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { try { const ws = workspaceRoot() scratch.push(ws) - const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } - scratch.push(record.tempDir) + const tempDir = shapedTempPath() + const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + scratch.push(tempDir) const first = await setup() const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) - // The constructor appends the `session/end-seed` marker, so the log is - // the seed plus that marker — exactly one acl record among them. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) expect(confined.argv).toContain('S-1-4-77-1') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) - // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) } finally { @@ -238,14 +213,93 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const parentTemp = shapedTempPath() + const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + scratch.push(parentTemp) + // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. + const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh + } finally { + cleanup() + } + }) + + it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // writeSid = Everyone: would widen the grant to every token. + const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } + ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) + const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } + expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) + expect(mockState.grants).toHaveLength(0) + + // tempDir outside the host temp root. + const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) + const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } + expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. + const preexisting = shapedTempPath() + mkdirSync(preexisting) + scratch.push(preexisting) + const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) + const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } + expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + + // Reparse point: same EEXIST (exclusive mkdir never follows links). + const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) + scratch.push(target) + const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name + symlinkSync(target, linkPath) + scratch.push(linkPath) + const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) + const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } + expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) expect(mockState.grants).toHaveLength(0) } finally { @@ -259,7 +313,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { await bare.plugin(LocalSandboxProvider, {}) const sandbox = bare.sandbox as LocalSandboxProvider sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') } expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) const { sandbox: withStore } = await setup() @@ -274,17 +328,17 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: the cleanup dispose() runs and the - // original error propagates unchanged. + // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) - // add() AND dispose() both throw: both surface as an AggregateError. + // add() AND dispose() both throw: AggregateError. mockState.grants = [] mockState.addFailure = new Error('grant exploded again') mockState.disposeFailure = new Error('cleanup exploded') @@ -308,7 +362,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toHaveLength(0) - // Disposing a provider with no grants is a no-op (the empty-map guard). await fiber.dispose() } finally { cleanup() @@ -320,9 +373,10 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) mockState.disposeFailure = new Error('revoke exploded') @@ -334,4 +388,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { cleanup() } }) + + it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => { + const a = basename(sessionTempDir()) + const b = basename(sessionTempDir()) + expect(a).not.toBe(b) + expect(a).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(b).toMatch(/^dsh-[0-9a-f]{16}$/) + }) }) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index e97ea17491..1f15765335 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -383,7 +383,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) it('reads a failing probe as unusable and walks to the next rung', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 834cebbe3f..3fb0ec6692 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 -README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e +README.md: 80d0502c3ace71da49e8f40efc6aa379286c74ac +README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index e9309caa87..80d0502c3a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. @@ -64,7 +64,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. - **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. @@ -80,6 +80,7 @@ None directly; the denial surface belongs to the tool layer. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eabf8f1c1e..b0c50af1b3 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 @@ -64,7 +64,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 - **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 @@ -80,6 +80,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index dd0124edda..309fe8fe50 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -123,6 +123,9 @@ export interface Win32Bindings { createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // Terminate a suspended child that could not be placed in the kill-on-close + // job — closing handles alone would leave it hanging forever. + terminateProcess(process: NativePtr, exitCode: number): number // ---- console ------------------------------------------------------------- // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): // the runner survives console Ctrl+C so the child handles its own and the @@ -417,6 +420,7 @@ function bindings(): Win32Bindings { createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32Bindings diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index f22dadfbb0..93e0e33c69 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -5,8 +5,14 @@ * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only * this sandbox instance adds to the target directories' DACLs — the * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else. Unlike the POC, every API failure throws with the - * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * ACE, and nowhere else the orphan SID is concerned (the token's write check + * ALSO inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, + * INTERACTIVE, and LOCAL are absent from both lists — see the seam's + * dual-list contract in `packages/sandbox/sandbox-local` and the package + * README's Modes section for the complete boundary). Unlike the POC, every + * API failure throws with the API name and exact Win32 code; a child is + * NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): * - writes are restricted; reads, network, and process visibility are NOT diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index b71efe2ac7..eafcb252ce 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -331,7 +331,11 @@ export function spawnSandboxedInherited( } if (api.assignProcessToJobObject(job, processHandle) === 0) { + // The child was created suspended and is NOT in the kill-on-close job: + // closing handles would leave it suspended forever. Terminate it first, + // then drop the handles and throw. const win32Code = api.getLastError() + api.terminateProcess(processHandle, 1) api.closeHandle(threadHandle) api.closeHandle(processHandle) api.closeHandle(job) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 14f370602c..0c1d7f42b3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -102,6 +102,32 @@ describe('spawn failure paths close their handles', () => { expect(closeHandle).toHaveBeenCalledTimes(3) expect(closed).toEqual([201n, 200n, 100n]) }) + + it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { + // The child is created suspended and is NOT in the kill-on-close job when + // the assignment fails: closing the job cannot kill it, so the failure + // branch must TerminateProcess first or every failure strands a hanging + // orphan forever. + const { api: baseApi, closeHandle } = resumeFailureApi() + type JobFailureApi = Win32Bindings & { + assignProcessToJobObject: ReturnType + terminateProcess: ReturnType + } + const api = baseApi as JobFailureApi + api.assignProcessToJobObject = vi.fn(() => 0) + api.terminateProcess = vi.fn(() => 1) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') + expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) + // thread, process, job — and the child is already dead before they close. + expect(closeHandle).toHaveBeenCalledTimes(3) + }) }) describe('getTempPath buffer defense', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index f2737948ca..2557b1f43b 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -43,7 +43,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { ]) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) // A sole candidate is selected unprobed. expect(probeWindowsAcl).not.toHaveBeenCalled() }) @@ -53,6 +53,6 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { const confined = sandbox.confine(['true'], RO) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) }) diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index deb703dc15..7c91afe76b 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -27,11 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index fee9164ead..6c2397a2e0 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, @@ -41,12 +42,12 @@ export interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } /** diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index af4de1c016..673ee51547 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } From 18859c408c16476b082aad86b2df7561e414d72a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 23:59:52 +0800 Subject: [PATCH 39/62] fix(sandbox): record the dsh-session dependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bdb9cf308..4df6dd66d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4641,6 +4641,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From db7bcbdf91099d150d9b4ce6ca3ea4000bae768f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 00:06:50 +0800 Subject: [PATCH 40/62] test(sandbox): cover the durable-record typeof guards and the empty-workspace branch --- .../sandbox-local/tests/acl-session.spec.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index a0e7a38390..51b3dec417 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -234,7 +234,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => { + it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -245,13 +245,26 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) - expect(mockState.grants).toHaveLength(0) // tempDir outside the host temp root. const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) + + // Non-string durable fields (a corrupted/tampered JSONL payload): the + // typeof guards fail loud before any string operation runs. + const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ + { id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ }, + { id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, + ] + for (const c of cases) { + ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId(c.id) } + expect(() => sandbox.confine(['true'], policy), c.id).toThrow(c.expect) + } expect(mockState.grants).toHaveLength(0) } finally { cleanup() From 2bfc8273ee7e29bfbf93a585b752ca1bb9de0fe9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 01:56:53 +0800 Subject: [PATCH 41/62] fix(pty-local): expect sessionId in resolved policy after merge-forward Master's pty-local rewrite dropped the sessionId expectation from the resolved-policy contract; sandbox-policy resolve() injects the owner sessionId (sandbox branch design). Restore it in both expectations. --- packages/pty/pty-local/tests/index.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d61eb58da5..dd3a956536 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -215,7 +215,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, }]) }) @@ -247,7 +247,7 @@ describe('LocalPtyBackend startup rollback', () => { }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, }]) }) From 55fd6e555ac1995b4a24032ebad2a3ce6b06c02b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 02:14:45 +0800 Subject: [PATCH 42/62] fix(knip): restore pwsh-sandbox entry body mangled by merge The merge-forward collapsed the pwsh-sandbox key with the parent's e2b entry into invalid JSON; restore the branch's original entry alongside the kept e2b entry. --- knip.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/knip.json b/knip.json index f39ee8d302..de77396c7c 100644 --- a/knip.json +++ b/knip.json @@ -233,6 +233,15 @@ ] }, "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/e2b/e2b": { "entry": [ "tests/**/*.spec.ts", From d18dc76b899129fa09f7bf672ae9712b49b2771e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 03:19:09 +0800 Subject: [PATCH 43/62] chore(docs): regenerate module graph for merged tree The merge-forward took the parent's module-graph verbatim; the merged tree adds the sandbox-windows-acl package and dsh-session edges. verify-module-graph is a ci-static gate, not in local doc-sync. --- docs/module-graph.md | 157 ++++++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 6f47c36e97..4ea724f789 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -43,6 +43,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -230,6 +231,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_scaffold["packages/scaffold"] pkg_helper["helper"] @@ -313,6 +315,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_type_meta --> pkg_invariants @@ -414,8 +417,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -426,13 +427,6 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_agent --> pkg_type_meta - pkg_bash --> pkg_invariants - pkg_bash --> pkg_sandbox - pkg_bash --> pkg_subprocess - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants @@ -491,9 +485,9 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session @@ -518,22 +512,13 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_type_meta - pkg_bash_local --> pkg_bash - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -542,9 +527,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_llm_replay --> pkg_compact pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm @@ -565,13 +547,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -597,16 +572,13 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_lsp_local --> pkg_brand - pkg_lsp_local --> pkg_fs - pkg_lsp_local --> pkg_invariants - pkg_lsp_local --> pkg_llm - pkg_lsp_local --> pkg_lsp - pkg_lsp_local --> pkg_subprocess - pkg_lsp_local --> pkg_timeout pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -664,16 +636,22 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants @@ -682,6 +660,9 @@ flowchart TD pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> pkg_session pkg_compact_tool_result_prune --> pkg_token_meter + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -705,6 +686,13 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -721,6 +709,13 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_fs + pkg_lsp_local --> pkg_invariants + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess + pkg_lsp_local --> pkg_timeout pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -757,6 +752,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -982,9 +992,12 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1193,6 +1206,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | @@ -1227,11 +1241,8 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1247,34 +1258,28 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1288,23 +1293,33 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1340,7 +1355,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | From 5fea4b7c4b86db5504b27fabd5023a3a1d03829d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:44:35 +0800 Subject: [PATCH 44/62] feat(sandbox): derive the windows-acl write SID per workspace, not per session The per-session random write SID forced a full tree propagation per session per server lifetime (minutes on large workspaces). The write SID is now the per-workspace identity derived from the canonical workspace path (workspaceWriteSid: sha256 -> S-1-4-x-y), stored nowhere: the workspace-root ACE materializes once per workspace per machine and every later provision hits the exact-ACE skip. - workspace ACEs are STANDING (never revoked - the reuse cache); temp ACEs stay revocable (disposed with the provider), so an inheritable ACE never outlives its session's temp dir on the ambient temp root - AclSandbox requires the write SID under workspace-write; read-only parses/grants nothing; the runner derives the SID itself (the --write-sid flag's presence still marks the seam-managed contract) - the acl-session record drops writeSid (sessionId/workspace/tempDir remain): the SID-tamper surface and its validation are gone - sandbox-local holds two grant maps: standing workspace grants and revocable per-session temp grants Docs (README pair, design note pair, catalogs, type-equiv) and the acl-session/grant/acl/probe/runner suites updated; workspace-sid.spec pins the derivation contract. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 10 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 7 +- docs/subsystems/sandbox.zh.md | 7 +- .../sandbox/sandbox-local/src/acl-session.ts | 67 ++++---- packages/sandbox/sandbox-local/src/index.ts | 121 +++++++++----- .../sandbox-local/tests/acl-session.spec.ts | 150 ++++++++++-------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 34 ++-- .../sandbox/sandbox-windows-acl/README.zh.md | 78 ++++----- .../sandbox/sandbox-windows-acl/src/grant.ts | 41 +++-- .../sandbox/sandbox-windows-acl/src/index.ts | 138 +++++++++------- .../sandbox/sandbox-windows-acl/src/runner.ts | 48 +++--- .../sandbox/sandbox-windows-acl/src/token.ts | 16 +- .../sandbox-windows-acl/src/workspace-sid.ts | 38 +++++ .../sandbox-windows-acl/tests/acl.spec.ts | 31 +++- .../sandbox-windows-acl/tests/grant.spec.ts | 24 ++- .../sandbox-windows-acl/tests/probe.spec.ts | 2 +- .../tests/workspace-sid.spec.ts | 30 ++++ packages/sandbox/sandbox/src/index.ts | 5 +- 24 files changed, 543 insertions(+), 330 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c9e3414e4d..98d1f7f653 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 +2026-08-08-windows-acl-restricted-token-sandbox.md: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 6c358c1bdc..54468fa46f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 028b7dedf5..10a412a2cb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e64a858e0a..8de276a4a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1231,7 +1231,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c1e057542f..c1eae60ced 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,16 +498,16 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ```ts persistence-catalog /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -517,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index abef39cbf9..489daf02e4 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/sandbox.md -sandbox.md: 37aba27c589be80d8a3ea9201fd8d13c7b259eda -sandbox.zh.md: 244e351a5db1bfb0d2c9069e8d07bd52f9d15bb0 +sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 +sandbox.zh.md: 0619046f2fa98a89275e2dad85f0eadaf8e4a231 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 37aba27c58..20e0f36a5e 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 244e351a5d..0619046f2f 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index 5247601250..2807ffd8a1 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -1,18 +1,24 @@ /** - * The windows-acl per-session write identity — the DURABLE half of the seam's - * per-session grant reuse. Each session owns exactly one record (one orphan - * write SID, one private temp subdirectory), stored as a log-only + * The windows-acl session write record — the DURABLE half of the seam's + * grant lifecycle. Each session owns exactly one record (its workspace + * binding plus one private temp subdirectory), stored as a log-only * `sandbox/acl-session` event on the session log (the `sandbox/mode` * precedent): replayable, never in the model transcript, and no external - * config store. The ACE half is server-lifetime state owned by the provider - * ({@link AclWriteGrant} materialization, revoked on dispose); the record - * survives restarts so a resumed session reuses the SAME SID — re-granting - * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. The record is BOUND to its owning - * session id, so a fork (which copies the parent's events, record included) - * never inherits the parent's identity — it provisions a fresh one. The - * record's payload is durable input and is validated at the fold (orphan-SID - * shape, well-formed temp path); a matching-but-tampered record fails loud. + * config store. The record carries NO SID: the write SID is the + * per-WORKSPACE identity derived from the workspace path + * (`workspaceWriteSid`) — deterministic across sessions and server + * restarts, so the workspace-root ACE materializes once per workspace per + * machine (the grant's exact-ACE skip makes every later provision O(1)) + * instead of once per session. The ACE half is server-lifetime state owned + * by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs + * revocable); the record survives restarts so a resumed session reuses the + * SAME private temp subdirectory and the same derived SID — re-granting + * idempotently merges into (or skips) the standing ACEs. The record is + * BOUND to its owning session id, so a fork (which copies the parent's + * events, record included) never inherits the parent's temp identity — it + * provisions a fresh one. The record's payload is durable input and is + * validated at the fold (well-formed workspace/temp paths); a + * matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ @@ -20,22 +26,21 @@ import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -48,24 +53,19 @@ declare module '@deepseek-ai/dsh-session' { /** The durable per-session record carried by one `sandbox/acl-session` event. */ export interface AclSessionRecord { - /** The orphan write SID whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session id (binds the record against fork inheritance). */ sessionId: SessionId - /** The workspace root the record was provisioned for. */ + /** The workspace root the record was provisioned for (the write SID derives from it). */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } -/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ -const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u - /** * The session's record: the last `sandbox/acl-session` event owned by it, or * undefined (never confined / a fork). Durable-input validation: tampered - * SID or temp path fails loud. @param events/@param sessionId/@returns as - * below. + * workspace or temp path fails loud. @param events/@param sessionId/@returns + * as below. * @param events - session events (other types skipped). * @param sessionId - owning session (fork binding). * @returns the last owned record, or undefined without one. @@ -77,12 +77,6 @@ export function sessionAclRecord(events: readonly SessionEvent[], sessionId: Ses const data = event.data // Fork copies the parent's record: skip non-owned records (fork mints fresh). if (data.sessionId !== sessionId) continue - if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` - + '(expected the orphan shape S-1-4-x-y)', - ) - } if (typeof data.workspace !== 'string' || data.workspace.length === 0) { throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) } @@ -112,18 +106,17 @@ export function sessionTempDir(): string { /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp subdirectory, - * appended as exactly one log-only `sandbox/acl-session` event — the - * provision IS its event, nothing mutates record state out of band. Fork - * (whose copied parent record is not its own) provisions a fresh record; - * resume replays the stored one. + * confined execution): the workspace binding plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (whose copied parent record is not its own) provisions a fresh + * record; resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. */ export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { - writeSid: randomWriteSid(), sessionId: session.id, workspace: workspaceRoot, tempDir: sessionTempDir(), diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index e18d68449a..0bf982607f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,12 +5,16 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung additionally owns the per-session write grant: one - * orphan write SID and one private temp subdirectory per session (durable - * record in the session log — see `./acl-session.ts`), ACEs materialized - * lazily at the session's first confined execution and held for the SERVER - * process's lifetime (revoked on dispose). The runner receives `--write-sid` - * and stops managing DACLs itself. + * The windows-acl rung additionally owns the write grants: the write SID is + * the per-WORKSPACE identity derived from the canonical workspace path + * (`workspaceWriteSid`), and one private temp subdirectory per session + * (durable record in the session log — see `./acl-session.ts`). The + * workspace-root ACE materializes once per workspace per server lifetime + * and STANDS (the cross-session reuse cache — the exact-ACE skip makes + * every later provision O(1) instead of re-propagating the tree per + * session); the private-temp ACEs are revoked on dispose. The runner + * receives `--write-sid` (the derived identity; its presence marks the + * seam-managed contract) and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ @@ -30,7 +34,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -227,9 +231,10 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the - * chain verdict and, on the windows-acl rung, the per-session write grants - * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the - * one-time probes spawn nothing else. + * chain verdict and, on the windows-acl rung, the write grants + * ({@link AclWriteGrant}: the standing workspace-root grant per workspace + * and the revocable private-temp grant per session, the latter revoked on + * provider dispose); the one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -248,11 +253,15 @@ export class LocalSandboxProvider extends SandboxProvider { /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined /** - * Server-lifetime per-session write grants (windows-acl rung), keyed by the - * session's orphan write SID — the native half of the per-session reuse; - * the durable half lives in the session log (`./acl-session.ts`). + * Server-lifetime write grants (windows-acl rung): the STANDING + * workspace-root grant per workspace (its ACE is the cross-session reuse + * cache and outlives the provider — never revoked) and the REVOCABLE + * private-temp grant per session (revoked on provider dispose); the + * durable half (workspace binding + private temp dir) lives in the + * session log (`./acl-session.ts`). */ - private readonly aclGrants = new Map() + private readonly workspaceGrants = new Map() + private readonly tempGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -274,9 +283,10 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) - // Standing ACL grants are revoked with the provider: a clean server - // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs - // the session's durable record re-grants idempotently on resume). + // The temp grants are revoked with the provider: a clean server + // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — + // the reuse cache; an unclean shutdown leaves them for the next + // provision's exact-ACE skip). ctx.effect(() => () => { this.revokeAclGrants() }) @@ -357,10 +367,11 @@ export class LocalSandboxProvider extends SandboxProvider { // Workspace-write sessions confine their temp writes to the PRIVATE // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only // runs pass the ambient temp root — the runner validates it exists - // but grants nothing. + // but grants nothing. The derived write SID is the per-workspace + // identity; the flag's presence marks the seam-managed DACL contract. '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), '--mode', policy.mode, - '--write-sid', record.writeSid, + '--write-sid', workspaceWriteSid(record.workspace), ] } @@ -399,8 +410,8 @@ export class LocalSandboxProvider extends SandboxProvider { // Immediate durability kick: the append is write-behind (bounded // coordinator window); flush now so the record is durable as close to // its ACE materialization as the synchronous confine seam allows. The - // residual window (a crash inside the flush latency) can strand inert - // orphan-SID ACEs — documented in the README. + // residual window (a crash inside the flush latency) strands the + // private temp directory unrecorded — documented in the README. void store.flush(session) return record } @@ -408,29 +419,49 @@ export class LocalSandboxProvider extends SandboxProvider { /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map - * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory — created here EXCLUSIVELY (the name is - * random and unguessable, a pre-existing entry throws EEXIST, and a - * reparse point is rejected, so the grant never lands on an - * attacker-placed object); read-only materializes NOTHING — its token - * alone restricts every write, and a standing grant from an earlier - * workspace-write period is KEPT through a downgrade (never revoked): the - * read-only restricted token carries no orphan SID (the read-only list), - * so the ACE is inert there, while the map hit keeps the re-upgrade free - * of re-propagation. Fail-closed: a half-materialized grant is revoked - * before the error propagates. + * hits are the whole call). The write SID is the per-workspace identity + * derived from the record's workspace. Workspace-write grants the + * workspace root STANDING (the ACE outlives every session — the reuse + * cache) and the private temp subdirectory REVOCABLY — created here + * EXCLUSIVELY (the name is random and unguessable, a pre-existing entry + * throws EEXIST, and a reparse point is rejected, so the grant never + * lands on an attacker-placed object); read-only materializes NOTHING — + * its token alone restricts every write, and the standing grant from an + * earlier workspace-write period is KEPT through a downgrade (never + * revoked): the read-only restricted token carries no write SID (the + * read-only list), so the ACE is inert there, while the map hit keeps the + * re-upgrade free of re-propagation. Fail-closed: a half-materialized + * temp grant is revoked before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { - if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return - const grant = AclWriteGrant.create(record.writeSid) + if (mode === 'read-only') return + const writeSid = workspaceWriteSid(record.workspace) + if (!this.workspaceGrants.has(record.workspace)) { + const grant = AclWriteGrant.create(writeSid) + try { + grant.add(record.workspace, true) + } catch (error) { + // Free the SID; a standing ACE (if the apply succeeded before a + // post-apply throw) is the intended end state, not an error + // artifact — nothing to revoke. + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + } + throw error + } + this.workspaceGrants.set(record.workspace, grant) + } + if (this.tempGrants.has(record.sessionId)) return + const grant = AclWriteGrant.create(writeSid) try { // Exclusive creation (no `recursive`): a pre-existing entry OR a // reparse point both fail EEXIST — the grant never lands on a foreign // object. mkdirSync(record.tempDir) - grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { // Revoke whatever stands and free the SID — never leave a half-grant @@ -438,30 +469,32 @@ export class LocalSandboxProvider extends SandboxProvider { try { grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.aclGrants.set(record.writeSid, grant) + this.tempGrants.set(record.sessionId, grant) } /** - * Revoke every standing per-session grant and free every SID (provider - * dispose). Cleanup failures are reported, not thrown: cordis teardown - * must not be aborted by grant revocation, and the durable records make a - * missed revocation self-healing on the next resume. + * Dispose every write grant (provider dispose): the revocable temp ACEs + * are revoked and every SID allocation freed; the standing workspace ACEs + * stay (the reuse cache). Cleanup failures are reported, not thrown: + * cordis teardown must not be aborted by grant cleanup, and the durable + * records make a missed revocation self-healing on the next resume. */ private revokeAclGrants(): void { - if (this.aclGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return const failures: unknown[] = [] - for (const grant of this.aclGrants.values()) { + for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { try { grant.dispose() } catch (error) { failures.push(error) } } - this.aclGrants.clear() + this.workspaceGrants.clear() + this.tempGrants.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 51b3dec417..ff04b97360 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,9 +1,11 @@ /** - * windows-acl per-session grant: the DURABLE record (log-event fold/provision - * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE - * materialization, through the REAL LocalSandboxProvider.confine() with a - * real session store. Win32 surface mocked at the package boundary; the - * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl write grants: the DURABLE record (log-event fold/provision with + * ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization (standing workspace grant per workspace, revocable temp + * grant per session), through the REAL LocalSandboxProvider.confine() with a + * real session store. Win32 surface mocked at the package boundary (the + * workspace-derived SID mocked to a constant); the real-FFI grant behavior + * lives in sandbox-windows-acl's win32 tests. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' @@ -19,7 +21,7 @@ import { sessionTempDir } from '../src/acl-session.ts' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ - grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -27,7 +29,7 @@ const mockState = vi.hoisted(() => ({ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { class MockAclWriteGrant { readonly writeSid: string - readonly added: string[] = [] + readonly added: Array<{ path: string; standing: boolean }> = [] disposed = false constructor(writeSid: string) { this.writeSid = writeSid @@ -36,20 +38,23 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { static create(writeSid: string): MockAclWriteGrant { return new MockAclWriteGrant(writeSid) } - add(path: string): void { + add(path: string, standing = false): void { if (mockState.addFailure !== undefined) throw mockState.addFailure - this.added.push(path) + this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } + return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } }) +/** The workspace-derived write SID the mock pins for every workspace. */ +const DERIVED_SID = 'S-1-4-42-42' + /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -72,7 +77,7 @@ function shapedTempPath(): string { return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) } -describe('windows-acl per-session grant (LocalSandboxProvider)', () => { +describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { @@ -85,7 +90,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { + it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() @@ -95,28 +100,40 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(confined.argv).toContain('--write-sid') - expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain(DERIVED_SID) expect(confined.argv).toContain('workspace-write') - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir scratch.push(tempDir) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + expect(mockState.grants[0]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked + disposed: false, + }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) expect(existsSync(tempDir)).toBe(true) // created exclusively expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the second confine is the map hit. + // Reuse: the second confine is the map hits. sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(session.events).toHaveLength(1) await fiber.dispose() + // dispose() runs on BOTH grants: the standing workspace ACE is left in + // place (the mock marks it disposed only as instance teardown). expect(mockState.grants[0]!.disposed).toBe(true) + expect(mockState.grants[1]!.disposed).toBe(true) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -134,28 +151,33 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { scratch.push(record.tempDir) expect(existsSync(record.tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the same SID. + // Upgrade: first workspace-write materializes with the derived SID. const upgraded = sandbox.confine(['true'], workspaceWrite) expect(upgraded.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', record.tempDir, '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: record.tempDir, standing: false }], + disposed: false, + }) expect(existsSync(record.tempDir)).toBe(true) - // Reuse: map hit. + // Reuse: map hits. sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { @@ -177,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { '--workspace', ws, '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) @@ -188,12 +210,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => { try { const ws = workspaceRoot() scratch.push(ws) const tempDir = shapedTempPath() - const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir } scratch.push(tempDir) const first = await setup() @@ -202,9 +224,9 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) - expect(confined.argv).toContain('S-1-4-77-1') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] }) // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) @@ -213,52 +235,52 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) const parentTemp = shapedTempPath() - const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } scratch.push(parentTemp) // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(mockState.grants).toHaveLength(2) + // Fresh temp identity, NOT the parent's (the workspace SID is shared by + // derivation — the workspace is the same). + const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir + expect(childTemp).not.toBe(parentTemp) + expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh } finally { cleanup() } }) - it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => { + it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - // writeSid = Everyone: would widen the grant to every token. - const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } - ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) - const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } - expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) - // tempDir outside the host temp root. - const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) // Non-string durable fields (a corrupted/tampered JSONL payload): the - // typeof guards fail loud before any string operation runs. + // typeof guards fail loud before any string operation runs. There is + // NO stored SID to tamper with — the write SID is derived from the + // workspace path, so the old "SID rewritten to Everyone" attack + // surface does not exist. const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ - { id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ }, - { id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, + { id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, ] for (const c of cases) { ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) @@ -271,7 +293,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -281,12 +303,15 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const preexisting = shapedTempPath() mkdirSync(preexisting) scratch.push(preexisting) - const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + // The standing workspace grant is the intended end state and stays; the + // failed temp grant self-disposes. + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked // Reparse point: same EEXIST (exclusive mkdir never follows links). const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) @@ -294,12 +319,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name symlinkSync(target, linkPath) scratch.push(linkPath) - const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants).toHaveLength(4) + expect(mockState.grants[3]!.disposed).toBe(true) } finally { cleanup() } @@ -310,7 +335,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } + const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) @@ -336,7 +361,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -344,7 +369,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. + // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) @@ -381,7 +406,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() @@ -390,12 +415,13 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) mockState.disposeFailure = new Error('revoke exploded') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await fiber.dispose() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + // BOTH grants (standing workspace + revocable temp) fail their dispose. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures')) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) } finally { cleanup() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 3fb0ec6692..7ba547bc10 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 80d0502c3ace71da49e8f40efc6aa379286c74ac -README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c +README.md: 3185510142750178b11c2f6bad863a0a4861a29c +README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 80d0502c3a..3185510142 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,29 +4,30 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). ## Usage ```ts -import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner @@ -36,17 +37,17 @@ The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrap node runner.js --workspace --temp --mode [--write-sid ] -- ``` -The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam still provisions ONE log-only `sandbox/acl-session` event per session (bound to the owning session id, validated at the fold) carrying the session's workspace binding and PRIVATE temp subdirectory: a resumed session replays the same temp dir, a fork mints a fresh one. The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable. The orphan stays OUT of the list on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. ## Header verification @@ -62,7 +63,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. -- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. - **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. @@ -78,11 +79,12 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). -- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. -- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. -- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. +- **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index b0c50af1b3..ab45d29c7d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,87 +2,89 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 +面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 -直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 +直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 ```ts -import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 ## 隔离 runner -面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: ```sh node runner.js --workspace --temp --mode [--write-sid ] -- ``` -runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID(先前每会话随机 SID 及其篡改面已移除)。seam 仍会为每个会话只供给一条仅作日志记录的 `sandbox/acl-session` 事件(绑定其所属会话 id,在 fold 处校验),携带会话的工作区绑定与**私有**临时子目录:恢复的会话回放同一个临时目录,fork 则铸造一个新的。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 -模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置。孤儿 SID 有意留在列表**之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 -Authenticated Users 在**两种**列表中都缺席——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)在**每一种**受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——模型可见面文档化的是这一契约,而非提示词承诺。INTERACTIVE/LOCAL 同样在**两种**列表中都缺席:宿主的 Public 树把写权限授予 INTERACTIVE,因此 Public 写入会被拒绝——由 runner 的环境可写 Public-probe 回归钉住(见设计笔记)。 +Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 -`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 +`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 -## 头文件查证 +## 头部验证 -所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: +所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 +koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 -## 已验证的边界(受限令牌固有,非本移植缺陷) +## 已验证边界(受限令牌固有,非本移植引入) -- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 -- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 -- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 +- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 +- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 +- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 位随机 hex>`,独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 -- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 -## 模型体验 +## Model Experience -经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 -无直接影响;拒绝呈现面属于工具层。 +无直接影响;拒绝面属于工具层。 -## 已知限制与后续工作 +## Known Limitations and Deferred Work -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 -- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 -- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 -- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 -- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 -- **宽目录与 FAT 卷警告留待后续;FAT 类目标保持可写。** 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**时只会让授权立即报错(无 ACL 支持)。位于授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查会通过(Everyone 在两种列表中都存在),这类目标在**两种**受限模式下都可写。FAT 视作历史残留——不支持、不工程化应对;这一仅警告性姿态在此记录成文,而非加以缓解。 -- **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 +- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 +- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 +- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 +- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 +- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 +- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 +- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7826922f1d..7cfd6e1a36 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -19,16 +19,22 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One orphan write SID's server-lifetime grant materialization: the parsed - * SID pointer plus every directory whose DACL currently carries its ACE. - * Create with {@link AclWriteGrant.create}; dispose revokes all. + * One write SID's server-lifetime grant materialization: the parsed SID + * pointer plus every directory whose DACL currently carries its ACE. + * Workspace paths are added STANDING (their ACEs are the cross-session reuse + * cache and outlive the grant — dispose() skips revoking them, or the next + * provision would re-propagate the whole tree); temp paths are revocable + * (dispose() revokes them — an inheritable ACE must not outlive its + * session's temp directory). Create with {@link AclWriteGrant.create}; + * dispose revokes the revocable paths and frees the SID. */ export class AclWriteGrant { - /** The orphan write SID in SDDL string form. */ + /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr - private readonly grantedPaths: string[] = [] + private readonly revocablePaths: string[] = [] + private readonly standingPaths: string[] = [] private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { this.api = api @@ -57,28 +63,31 @@ export class AclWriteGrant { /** * Grant the write ACE on one directory (idempotent: an already-standing * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose}. The path is - * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after - * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an - * ungranted path is a no-op merge. Callers treat a throw as a failed - * materialization and dispose the instance to revoke the paths granted so - * far. + * {@link grantWrite}) and record the path for {@link dispose} unless it is + * standing. The path is recorded BEFORE the grant: a post-apply throw (a + * LocalFree failure after SetNamedSecurityInfoW succeeded) must still + * revoke it, and revoking an ungranted path is a no-op merge. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. * @param path - the directory whose DACL gains the grant. + * @param standing - the ACE outlives this grant (the workspace reuse + * cache; dispose() skips revoking it). Default false (revoked on + * dispose — the temp-directory lifecycle). */ - add(path: string): void { - this.grantedPaths.push(path) + add(path: string, standing = false): void { + ;(standing ? this.standingPaths : this.revocablePaths).push(path) grantWrite(this.api, path, this.sidPtr) } /** Every directory currently carrying the grant, in grant order. */ get paths(): readonly string[] { - return this.grantedPaths + return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] - for (const path of this.grantedPaths) { + for (const path of this.revocablePaths) { try { revokeWrite(this.api, path, this.sidPtr) } catch (error) { diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 93e0e33c69..6180927123 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,17 +2,21 @@ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED - * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only - * this sandbox instance adds to the target directories' DACLs — the - * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else the orphan SID is concerned (the token's write check - * ALSO inherits the ambient write ACEs of the other restricting SIDs — the - * keep-alive group logon SID + Everyone; Authenticated Users, - * INTERACTIVE, and LOCAL are absent from both lists — see the seam's - * dual-list contract in `packages/sandbox/sandbox-local` and the package - * README's Modes section for the complete boundary). Unlike the POC, every - * API failure throws with the API name and exact Win32 code; a child is - * NEVER spawned unrestricted. + * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only + * this sandbox adds to the target directories' DACLs — the intersection + * check then allows writes exactly where that SID has a Write ACE, and + * nowhere else the write SID is concerned (the token's write check ALSO + * inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, + * and LOCAL are absent from both lists — see the seam's dual-list contract + * in `packages/sandbox/sandbox-local` and the package README's Modes section + * for the complete boundary). The write SID is the per-WORKSPACE identity + * ({@link workspaceWriteSid}): deterministic from the canonical workspace + * path, so the workspace-root ACE materializes once per workspace per + * machine and every later provision hits the exact-ACE skip — the + * grant-reuse story the per-session random SID paid a full tree propagation + * per session for. Unlike the POC, every API failure throws with the API + * name and exact Win32 code; a child is NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): * - writes are restricted; reads, network, and process visibility are NOT @@ -22,17 +26,19 @@ * STATUS_DLL_INIT_FAILED under the restriction); * - the temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); - * - grants are standing ACE mutations on real directories — revoke them via - * dispose() before the process exits (the POC's documented - * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). With `manageDacls: false` the CALLER owns - * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() - * skip grant/revoke entirely and the caller must not revoke under live - * children. + * - grants are standing ACE mutations on real directories. WORKSPACE grants + * are deliberately never revoked — the ACE is the cross-session reuse + * cache (revoking would force the next session to re-propagate the whole + * tree). TEMP grants are revocable: dispose() removes them so a standing + * inheritable ACE never outlives its session's temp directory (an + * inheritable ACE on the ambient temp root would otherwise widen the + * SID's write reach to every future temp file). With `manageDacls: false` + * the CALLER owns the DACLs (the sandbox seam's grant reuse): + * init()/dispose() skip grant/revoke entirely and the caller must not + * revoke under live children. * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { randomInt } from 'node:crypto' import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' @@ -46,6 +52,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' +export { workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -58,7 +65,13 @@ export interface AclSandboxOptions { * allowance — not even the NUL device is writable, see README). */ tempDir?: string | null - /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + /** + * The write SID forming the workspace-write allowlist: REQUIRED under + * workspace-write, ignored (and must be absent) under read-only. Callers + * derive it from the workspace via {@link workspaceWriteSid} — the identity + * is per workspace, not per sandbox instance, so the workspace-root ACE + * outlives every instance and later provisions hit the exact-ACE skip. + */ writeSid?: string /** * The file-effect mode this instance confines under — selects the @@ -109,25 +122,20 @@ export interface AclSandboxChild { wait(): Promise } -/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). - * @returns the SDDL string form. - */ -export function randomWriteSid(): string { - return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` -} - /** - * One write-restricted sandbox instance: token + orphan-SID grants + spawn. - * `init()` is fail-closed — any Win32 failure revokes whatever was granted - * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. With `manageDacls: false` the caller owns the grants (per-session + * One write-restricted sandbox instance: token + write-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) + * grants and throws; `dispose()` revokes the temp grants, leaves the + * standing workspace ACEs in place (the cross-instance reuse cache), frees + * every allocation, and reports every cleanup failure. With + * `manageDacls: false` the caller owns the grants (the sandbox seam's grant * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The orphan SID string whose ACEs form the write allowlist. */ - readonly writeSid: string + /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + readonly writeSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined @@ -151,7 +159,10 @@ export class AclSandbox { return absolute }) this.tempDirOption = options.tempDir - this.writeSid = options.writeSid ?? randomWriteSid() + this.writeSid = options.writeSid + if (this.mode === 'workspace-write' && this.writeSid === undefined) { + throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -166,14 +177,19 @@ export class AclSandbox { const currentToken = openCurrentProcessToken(api) try { - const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + // Read-only runs carry no write SID (its restricting list has no + // orphan): nothing to parse, nothing to grant. + let writeSidPtr: NativePtr | undefined + if (this.writeSid !== undefined) { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + writeSidPtr = parsedSid } - const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - const writeSidPtr = parsedSid const tempDir = this.tempDirOption === null ? null @@ -185,16 +201,26 @@ export class AclSandbox { this.tempDirResolved = tempDir } - // manageDacls: false — the caller (the sandbox seam's per-session grant) - // already materialized the ACEs; this instance must neither add nor - // remove any (its dispose() must not revoke the caller's standing grant). + // manageDacls: false — the caller (the sandbox seam's grant) already + // materialized the ACEs; this instance must neither add nor remove any. + // When this instance owns the DACLs, writableDir ACEs are STANDING (the + // per-workspace reuse cache — dispose() never revokes them, or the next + // provision would re-propagate the whole tree) and the temp ACE is + // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient + // temp root must not outlive the instance, or it would widen the SID's + // write reach to every future temp file). if (this.manageDacls) { - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - // Record BEFORE granting: grantWrite can throw after a successful - // apply (a LocalFree failure), and the fail-closed catch must still - // revoke that path (revoking an ungranted path is a no-op merge). - this.grantedPaths.push(path) - grantWrite(api, path, writeSidPtr) + if (writeSidPtr !== undefined) { + for (const path of this.writableDirs) { + grantWrite(api, path, writeSidPtr) + } + if (tempDir !== null) { + // Record BEFORE granting: grantWrite can throw after a successful + // apply (a LocalFree failure), and the fail-closed catch must still + // revoke that path (revoking an ungranted path is a no-op merge). + this.grantedPaths.push(tempDir) + grantWrite(api, tempDir, writeSidPtr) + } } } const logonSid = findLogonSid(api, currentToken) @@ -212,8 +238,10 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave standing grants or SID allocations - // behind a failed init. + // Fail-closed cleanup: never leave a revocable (temp) grant or SID + // allocation behind a failed init. Standing workspace ACEs are NOT + // revoked — they are the intended end state (the reuse cache), not an + // error artifact. const cleanupFailures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { @@ -293,7 +321,11 @@ export class AclSandbox { } } - /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + /** + * Revoke the revocable (temp) grants, free the SID, close the token; the + * standing workspace ACEs stay (the reuse cache). Reports every cleanup + * failure. + */ dispose(): void { const api = this.api if (api === undefined) return diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 3487e182be..93f8cfcc01 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,9 +1,10 @@ /** * The windows-acl confinement runner: the argv-prefix wrapper the sandbox * seam spawns in place of the caller's command. It creates the - * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped - * argv under it with the CALLER'S stdio inherited (bytes flow straight - * through), mirrors the child's exit code, and revokes all grants on exit. + * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the + * wrapped argv under it with the CALLER'S stdio inherited (bytes flow + * straight through), mirrors the child's exit code, and revokes its temp + * grant on exit (workspace ACEs stay standing as the reuse cache). * * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): @@ -22,19 +23,23 @@ * Public tree writes are denied); the two lists share the keep-alive group * (logon SID, EVERYONE) and differ only by the orphan. * - * `--write-sid`: the seam's per-session grant contract — the CALLER has - * already materialized the orphan-SID ACEs (once per session, server - * lifetime) and owns their revocation, so the runner neither grants nor - * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) - * the runner self-manages grants per invocation as before. With - * `--write-sid` in workspace-write mode, the runner rewrites the TMP/TEMP - * entries of its OWN environment (SetEnvironmentVariableW) to the `--temp` - * directory — a PRIVATE per-session temp subdirectory the seam provisions - * (bwrap `--tmpfs /tmp` semantics) — and the child inherits the rewritten - * block (lpEnvironment NULL; an explicit block through koffi trips - * ERROR_INVALID_PARAMETER in CreateProcessAsUserW, verified empirically). - * Read-only leaves the ambient temp entries untouched (writes there are - * denied anyway). + * `--write-sid`: the seam's grant contract — the CALLER has already + * materialized the write-SID ACEs (the seam's workspace + private-temp + * grants, server lifetime) and owns their revocation, so the runner neither + * grants nor revokes (manageDacls: false). The carried SID is the + * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it + * from the policy root; the flag's PRESENCE is the seam-managed marker (its + * value must equal the workspace-derived SID). Absent `--write-sid` + * (standalone/test use) the runner self-manages grants per invocation with + * the same workspace-derived SID (its workspace ACEs are standing — the + * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in + * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN + * environment (SetEnvironmentVariableW) to the `--temp` directory — a + * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs + * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in + * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient + * temp entries untouched (writes there are denied anyway). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -47,6 +52,7 @@ import { existsSync, statSync } from 'node:fs' import { win32 } from './ffi.ts' import { AclSandbox } from './index.ts' +import { workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -121,13 +127,17 @@ async function main(): Promise { fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) } + // The write SID is the per-workspace identity in BOTH flows; the flag's + // presence (seam-derived, or the self-managed derivation) selects who + // owns the DACLs below. + const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined const sandbox = new AclSandbox({ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, mode: parsed.mode, - ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, - // With --write-sid the seam owns the DACLs (per-session grants): this - // invocation must neither add nor revoke ACEs. + ...writeSid === undefined ? {} : { writeSid }, + // With --write-sid the seam owns the DACLs (workspace + private-temp + // grants): this invocation must neither add nor revoke ACEs. manageDacls: parsed.writeSid === undefined, }) await sandbox.init() diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index bd0f71ee9f..b0f9ea8f45 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -114,13 +114,13 @@ export interface RestrictingSidSet { * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — - * pwsh crashes 0xE0434352) fails without them. The orphan SID joins ONLY - * workspace-write — read-only carries no orphan, so a standing grant ACE + * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED * pass-2 check grants only what the restricting list carries, keeping * read-only strictly zero-grant even with stale ACEs standing, while the - * unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no + * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no * re-propagation). Authenticated Users is absent from BOTH lists: the WMI * namespace security check fails (0x80041003), so CIM is unavailable in * every confined mode, and the C:\-root tree-creation escape (standing @@ -133,22 +133,24 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist (workspace-write only). + * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the orphan). + * @param mode - selects the restricting list (workspace-write adds the write SID). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr, + writeSid: NativePtr | undefined, known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, writeSid]) + : writeSid === undefined + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() + : [logonSid, known.world, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts new file mode 100644 index 0000000000..db74893f36 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -0,0 +1,38 @@ +/** + * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived + * from the canonical workspace path, whose ACEs form that workspace's write + * allowlist. Every confined execution of the same workspace — across + * sessions, server restarts, and calls — carries the SAME write SID, so the + * workspace-root ACE materializes once per workspace per machine (the + * grant's exact-ACE skip then makes every later provision O(1)) instead of + * once per session. The SID's power is defined solely by the ACEs that name + * it (which exist only on the workspace tree and the session's private temp + * directory), and only tokens minted for that workspace carry it — the SID + * string itself is not a secret (the previous per-session SID was likewise + * logged in the plain). + * + * The input MUST be the canonical workspace path (`realpathSync.native` on + * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): + * canonicalization converges case/alias spellings, so two spellings of one + * workspace derive one SID; an as-spelled fallback path would mint a second + * identity for the same directory (self-healing, at the cost of one extra + * tree propagation). Renaming the workspace directory derives a new SID — + * the old standing ACEs are inert residue, and the next session re-propagates + * once. + * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid + */ + +import { createHash } from 'node:crypto' + +/** + * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, + * matching the orphan shape the token and ACE layers already carry). + * @param workspaceRoot - the canonical workspace path. + * @returns the SDDL string form. + */ +export function workspaceWriteSid(workspaceRoot: string): string { + const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 2e4c5baab7..25abe14392 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -17,6 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' +import { createRestrictedToken } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -170,18 +171,42 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) - it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => { const api = await win32() const dir = scratch() const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() + // Workspace ACEs are STANDING: dispose frees the instance's SID + // allocations but deliberately leaves the ACEs — they are the reuse + // cache the next provision's exact-ACE skip consumes. sandboxA.dispose() sandboxB.dispose() const aces = readDirectAces(api, dir) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) + }) + + it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + const api = await win32() + const workspaceDir = scratch() + const tempDir = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + sandbox.dispose() + const workspaceAces = readDirectAces(api, workspaceDir) + expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + const tempAces = readDirectAces(api, tempDir) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + }) + + it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { + const dir = scratch() + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) + .toThrow(/requires a write SID/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) + .toThrow(/requires the write SID/) }) it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts index 02090e2fe9..43810249ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -1,8 +1,10 @@ /** - * AclWriteGrant tests: the server-side per-session grant materialization — - * SID parsing fail-closed, ACE add/dispose round-trip against the REAL - * directory DACL (observed through icacls, the operator's own tool), and - * the recorded path order. Win32-only, like the other real-FFI suites. + * AclWriteGrant tests: the server-side grant materialization — SID parsing + * fail-closed, ACE add/dispose round-trip against the REAL directory DACL + * (observed through icacls, the operator's own tool), the recorded path + * order, and the standing/revocable lifecycle split (workspace ACEs outlive + * dispose as the reuse cache; temp ACEs revoke). Win32-only, like the other + * real-FFI suites. */ import { spawnSync } from 'node:child_process' @@ -38,18 +40,24 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) }) - it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + it('add materializes the ACE (idempotently) and reports grant order; dispose revokes revocable paths and keeps standing paths standing', () => { const dir = scratch() + const standingDir = scratch() const grant = AclWriteGrant.create('S-1-4-9000-77') - grant.add(dir) - expect(grant.paths).toEqual([dir]) + grant.add(dir) // revocable: the session-temp lifecycle + grant.add(standingDir, true) // standing: the workspace reuse cache + expect(grant.paths).toEqual([standingDir, dir]) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') // A second add over the standing exact ACE is a DACL-read no-op: the - // grant stays exactly one ACE (per-session reuse after a restart). + // grant stays exactly one ACE (the reuse across sessions/restarts). grant.add(dir) + grant.add(standingDir, true) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') grant.dispose() expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') }) it('two grants with different SIDs coexist and revoke independently', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0eb78e16f4..0825695438 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // block, which host runtimes (vitest worker pools) may not keep in sync // with process.env — and a real-temp grant would inherit over every // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, mode: 'workspace-write' }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) await sandbox.init() }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts new file mode 100644 index 0000000000..4d24c6f8fb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -0,0 +1,30 @@ +/** + * workspaceWriteSid tests: the per-workspace write identity is deterministic + * (the same canonical path always derives the same SID — the property the + * cross-session grant reuse rests on), orphan-shaped, distinct across + * workspaces, and byte-sensitive (the canonical path is the caller's + * contract; an alias spelling derives a second identity, self-healing at + * the cost of one extra tree propagation). + */ + +import { describe, expect, it } from 'vitest' + +import { workspaceWriteSid } from '../src/index.ts' + +describe('workspaceWriteSid', () => { + it('derives a stable orphan-shaped SID per workspace path', () => { + const first = workspaceWriteSid('C:\\Users\\agent\\repo') + const second = workspaceWriteSid('C:\\Users\\agent\\repo') + expect(first).toBe(second) + expect(first).toMatch(/^S-1-4-\d+-\d+$/u) + }) + + it('derives distinct identities for distinct workspaces', () => { + expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) + }) + + it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => { + expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo')) + expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) + }) +}) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 6c2397a2e0..4510e9a69a 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -44,8 +44,9 @@ export interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } From 8119bc34015c399c69a23fec5af63c8a4d45866f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:51:02 +0800 Subject: [PATCH 45/62] test(sandbox): fix grant-count and dispose-warning assertions The reparse case reuses the standing workspace grant of the preceding case (same workspace -> map hit), so the failed temp grant is the third grant, not the fourth; the dispose-warning text carries 'failure(s)'. --- packages/sandbox/sandbox-local/tests/acl-session.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index ff04b97360..560b16e865 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -323,8 +323,10 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(4) - expect(mockState.grants[3]!.disposed).toBe(true) + // Same workspace as the preexisting case: the standing workspace grant + // is the map hit (not recreated) — only the failed temp grant joins. + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]!.disposed).toBe(true) } finally { cleanup() } @@ -421,7 +423,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await fiber.dispose() // BOTH grants (standing workspace + revocable temp) fail their dispose. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) } finally { cleanup() From bb507830028899923328d7ba779754ef0cac8a53 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:57:01 +0800 Subject: [PATCH 46/62] test(sandbox): cover the temp-grant cleanup-failure AggregateError path --- .../sandbox-local/tests/acl-session.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 560b16e865..c6ee33f3e8 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -327,6 +327,20 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // is the map hit (not recreated) — only the failed temp grant joins. expect(mockState.grants).toHaveLength(3) expect(mockState.grants[2]!.disposed).toBe(true) + + // Temp-side cleanup failure: the standing workspace grant stays (map + // hit), the exclusive mkdir fails, AND the temp grant's dispose also + // fails — the temp cleanup AggregateError propagates. + mockState.grants = [] + mockState.disposeFailure = new Error('temp cleanup exploded') + const dupTemp = shapedTempPath().replace(/abab$/, 'efef') + mkdirSync(dupTemp) + scratch.push(dupTemp) + const dupRecord = { sessionId: SessionId('temp-cleanup-fail'), workspace: ws, tempDir: dupTemp } + ctx.sessions.create(SessionId('temp-cleanup-fail'), { seed: [recordEvent(dupRecord)], meta: { cwd: ws } }) + const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } + expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) + expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) } finally { cleanup() } From 01a3b454f6cfefd3770cf409755fcad4f6051edb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 12:34:48 +0800 Subject: [PATCH 47/62] fix(sandbox): extend the restricted token's default DACL with a write-SID ACE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New objects created without an explicit security descriptor take their DACL from the token's default DACL, which CreateRestrictedToken builds from the user's ambient SIDs — none of them a restricting SID. Confined children therefore failed the write pass-2 check when creating anonymous pipes (CreatePipe: ERROR_ACCESS_DENIED, surfaced as Node EPERM), breaking PowerShell pipelines and other CreatePipe consumers. Merge a full-access write-SID ACE (Everyone under read-only) into the token default DACL at init via SetTokenInformation(TokenDefaultDacl). Named pipes are EXEMPT: their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which no token change influences, so libuv's piped stdio capture stays denied for confined grandchildren — the POC-documented boundary, now pinned by the runner suite (inherit/ignore OK, pipe DENIED) and documented in the README pair. The NUL paragraph is corrected to the measured matrix (Everyone has 0x1201BF on the device: cmd/node writes land; Set-Content fails at the PS layer). --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 3 +- .../sandbox/sandbox-windows-acl/README.zh.md | 3 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + .../sandbox/sandbox-windows-acl/src/index.ts | 17 ++++-- .../sandbox/sandbox-windows-acl/src/token.ts | 52 +++++++++++++++++++ .../sandbox-windows-acl/src/win32-abi.ts | 11 ++++ .../sandbox-windows-acl/tests/runner.spec.ts | 31 +++++++++++ 11 files changed, 120 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 98d1f7f653..d11e67f811 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e +2026-08-08-windows-acl-restricted-token-sandbox.md: adbec6f733dcf2903e11b70726a07a9aae211fec +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e35f4f0a20e7f58e24a8ad47964f43d6c9c43681 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 54468fa46f..adbec6f733 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 10a412a2cb..e35f4f0a20 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权、Everyone 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 7ba547bc10..127b03c26d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 3185510142750178b11c2f6bad863a0a4861a29c -README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581 +README.md: 835b7dace0baf10eec78b35172109a37688d99c2 +README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 3185510142..835b7dace0 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -43,7 +43,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). @@ -83,6 +83,7 @@ None directly; the denial surface belongs to the tool layer. - **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. +- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. - **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index ab45d29c7d..82a890d75f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -43,7 +43,7 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 @@ -83,6 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 309fe8fe50..99b3cfaff3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -76,6 +76,7 @@ export interface Win32Bindings { copySid(length: number, destination: NativePtr, source: NativePtr): number // ---- token information --------------------------------------------------- getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number // ---- restricted token ---------------------------------------------------- createRestrictedToken( existing: NativePtr, flags: number, @@ -392,6 +393,7 @@ function bindings(): Win32Bindings { getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 6180927123..efa966a441 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -47,7 +47,7 @@ import { Win32Error } from './errors.ts' import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' -import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' @@ -61,8 +61,9 @@ export interface AclSandboxOptions { writableDirs: readonly string[] /** * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO temp grant (strict zero write - * allowance — not even the NUL device is writable, see README). + * Pass null for read-only confinement: NO temp grant (strict zero grant on + * the filesystem; the NUL device stays ambient-writable via Everyone — see + * README). */ tempDir?: string | null /** @@ -232,6 +233,16 @@ export class AclSandbox { { world: worldSid }, this.mode, ) + // The restricted token's default DACL still names only the user's + // ambient SIDs — none of the restricting SIDs. Every NEW object the + // confined process creates (anonymous stdio pipes, sync objects) takes + // its DACL from that default, so the write pass-2 check would deny + // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every + // piped-stdio grandchild spawn. Merge a full-access ACE for a + // restricting SID (the write SID under workspace-write, Everyone under + // read-only): new-object creation stays gated by the parent object's + // DACL, while the new object's own DACL passes pass-2. + setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) this.token = restricted if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') this.api = api diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index b0f9ea8f45..e6254acc03 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -9,6 +9,7 @@ import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { buildExplicitAccess } from './acl.ts' import * as abi from './win32-abi.ts' /** @@ -92,6 +93,57 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { return sid } +/** + * Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL + * — the DACL every NEW object the token holder creates (without an explicit + * security descriptor) takes. The restricted token inherits the user's + * default DACL verbatim, which names no restricting SID: a new anonymous pipe + * (child stdio) therefore fails the write pass-2 check at creation + * (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every + * piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the + * write SID under workspace-write, Everyone under read-only), so each new + * object's own DACL passes pass-2 while object creation itself stays gated by + * the parent container's DACL (files outside the granted trees remain + * uncreatable). Fails closed: any Win32 failure throws before the spawn. + * @param api - the binding table. + * @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT). + * @param sidPtr - the restricting SID whose full-access ACE joins the default DACL. + */ +export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query') + const buffer = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl') + } + const currentDacl = decodePtrAt(buffer, 0) + if (currentDacl === null) { + throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend') + } + const newDaclSlot = allocPtrSlot() + const result = api.setEntriesInAclW( + 1, + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS), + currentDacl, + newDaclSlot, + ) + if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge') + const newDacl = decodePtr(newDaclSlot) + if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL') + // TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the + // pointer; SetTokenInformation copies the ACL before returning. + const info = Buffer.alloc(8) + info.writeBigUInt64LE(newDacl, 0) + if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) { + const win32Code = api.getLastError() + api.localFree(newDacl) + throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl') + } + api.localFree(newDacl) +} + /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index cc250c2f9e..8e85eced3c 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -70,6 +70,15 @@ export const FILE_DELETE_CHILD = 0x0040 */ export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 +/** + * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE + * | 0x1FF): full file-object access. The mask of the ACE merged into the + * restricted token's DEFAULT DACL — the token holder must keep full access to + * every NEW object it creates (pipes included), and the ACE must name a + * restricting SID so the write pass-2 check passes at creation. + */ +export const FILE_ALL_ACCESS = 0x1F01FF + // CreateRestrictedToken flags (winnt.h lines ~4284) /** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ export const DISABLE_MAX_PRIVILEGE = 0x1 @@ -85,6 +94,8 @@ export const WinWorldSid = 1 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) /** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ export const TokenGroups = 2 +/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */ +export const TokenDefaultDacl = 6 // SECURITY_INFORMATION (winnt.h line ~4293) /** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 2ce251c308..bc64ad11de 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -189,6 +189,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { + // Two-layer pin of the grandchild-spawn boundary: + // - the token default DACL carries a restricting-SID ACE (set in init), + // so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL + // consumer) works and inherited/ignored stdio spawns succeed; + // - libuv's pipe-stdio uses NAMED pipes, whose default security + // descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins + // full, Everyone read-only) — NOT the token default DACL — so the + // client-end open requests write access no restricting SID is + // granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the + // POC-documented "no output redirection" boundary of WRITE_RESTRICTED + // tokens; piped capture cannot work and is pinned as DENIED. + const probe = [ + "const { spawnSync } = require('child_process');", + "const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };", + "t('inherit', { stdio: 'inherit' });", + "t('ignore', { stdio: 'ignore' });", + "t('pipe', { stdio: 'pipe' });", + ].join('') + for (const mode of ['workspace-write', 'read-only'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'node', '-e', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK') + expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK') + expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED') + } + }, 30_000) + it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After From 8f2c7c5047d8b81cec9763627f0af6104e9a4eef Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 16:34:09 +0800 Subject: [PATCH 48/62] docs(sandbox): attribute the named-pipe default SD to the Win32-layer user-mode template CreateNamedPipeW with NULL security attributes does not install a kernel template: KernelBase builds the documented 5-ACE default SD in user mode and passes it down; the kernel itself (a raw SD-null create) applies the token default DACL. Correct the claim in both README sides and the runner.spec pin comment, and link the MS template documentation. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 8 +++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 127b03c26d..57ce89dba1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 835b7dace0baf10eec78b35172109a37688d99c2 -README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63 +README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d +README.zh.md: e688218b2c12a529981433777a1163e3648c918a diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 835b7dace0..6f649f5dcc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -83,7 +83,7 @@ None directly; the denial surface belongs to the tool layer. - **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. -- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. +- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. - **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 82a890d75f..e688218b2c 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -83,7 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 -- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 +- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index bc64ad11de..30229d6206 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -195,9 +195,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL // consumer) works and inherited/ignored stdio spawns succeed; // - libuv's pipe-stdio uses NAMED pipes, whose default security - // descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins - // full, Everyone read-only) — NOT the token default DACL — so the - // client-end open requests write access no restricting SID is + // descriptor is the Win32 layer's user-mode default SD template + // (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS + // read-only) — NOT the token default DACL, which is what the kernel + // applies to a raw SD-null create — so the client-end open requests + // write access no restricting SID is // granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the // POC-documented "no output redirection" boundary of WRITE_RESTRICTED // tokens; piped capture cannot work and is pinned as DENIED. From 33e4c6580fe428ab14d1e30ba3a679a2febe77da Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 16:34:25 +0800 Subject: [PATCH 49/62] feat(tool-pwsh): teach the confined named-pipe capture boundary to the model Under read-only and workspace-write the Windows ACL sandbox leaves programs unable to open named pipes, so a piped-stdio spawn fails with EPERM. State that boundary in the pwsh tool description next to the ConstrainedLanguage contract, pin it in the tool tests, and bring the package README and both implemented Agent Notes current with it. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 ++-- .../2026-08-01-pwsh-tool-and-executor.md | 4 ++-- .../2026-08-01-pwsh-tool-and-executor.zh.md | 4 ++-- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- ...08-windows-acl-restricted-token-sandbox.md | 4 ++-- ...windows-acl-restricted-token-sandbox.zh.md | 4 ++-- packages/bash/tool-pwsh/README.i18n.yaml | 4 ++-- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 19 +++++++++++++------ packages/bash/tool-pwsh/tests/tools.spec.ts | 7 +++++-- 11 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index 45c2df39d0..3594c1f5d0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.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-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: a511035d959ed873d75af87e9f56374dd4cd4f27 -2026-08-01-pwsh-tool-and-executor.zh.md: c0e6a73228e08f2b5f2d5a6ccfb9a6eecce3ba7d +2026-08-01-pwsh-tool-and-executor.md: 855d8e5db8a78e7e798061724d89fde31b28e975 +2026-08-01-pwsh-tool-and-executor.zh.md: a29967d628640eafd2362b74528ee14172f19376 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index a511035d95..855d8e5db8 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -13,7 +13,7 @@ The harness spoke one shell dialect on every platform: `bash`. Windows hosts cou Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus a Windows-specific ConstrainedLanguage contract in the tool description. The parity decision supersedes this note's minimal-profile tool description. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus the Windows-specific ConstrainedLanguage and named-pipe contracts in the tool description. The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. @@ -30,6 +30,6 @@ The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash of ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage boundary precisely. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage and named-pipe boundaries precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index c0e6a73228..a29967d628 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -13,7 +13,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 契约。parity 决策取代了本 note 的最小画像工具描述。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 与 named-pipe 契约。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 @@ -30,6 +30,6 @@ Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇与 ConstrainedLanguage 边界。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇,以及 ConstrainedLanguage 与 named-pipe 边界。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index d11e67f811..f2514c3cb7 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: adbec6f733dcf2903e11b70726a07a9aae211fec -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e35f4f0a20e7f58e24a8ad47964f43d6c9c43681 +2026-08-08-windows-acl-restricted-token-sandbox.md: d6915cadb5817679749474c1046d1c715adc2b9b +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 1533e39bf5c239730b6e027e5f0b345d4c6c42e4 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index adbec6f733..d6915cadb5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index e35f4f0a20..1533e39bf5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index fa9af8edce..063985278f 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: c0c253fe24bd7eaa4f4d814141e17a67f10f4fb8 -README.zh.md: 7e672bfb4fbda8f683cab3dbb018624d43b2a6d2 +README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 +README.zh.md: bc0d6ca6099a729db56b7e0de9dfa23a0a6190b8 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index c0c253fe24..3fd5a53946 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **ConstrainedLanguage under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The tool description teaches this contract to the model; the backend README owns the full limitation. +- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 7e672bfb4f..bc0d6ca609 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **Windows sandbox 下的 ConstrainedLanguage** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。工具描述把这一契约教给模型;后端 README 负责完整的限制说明。 +- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个契约教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index fb07683c96..dcf60999b7 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,16 +114,23 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base - // The CLM contract below is Windows-restricted-token behavior, but the gate - // is 'any confining executor is mounted' (escalationModes non-empty). The - // conflation is safe today because every shipped composition pairing - // tool-pwsh with a confining executor is win32-only; a future POSIX - // pwsh-sandbox composition must gate the CLM sentence on the platform - // instead (tracked in the pwsh-tool-and-executor Agent Note). + // The CLM and named-pipe contracts below are Windows-restricted-token + // behavior, but the gate is 'any confining executor is mounted' + // (escalationModes non-empty). The conflation is safe today because every + // shipped composition pairing tool-pwsh with a confining executor is + // win32-only; a future POSIX pwsh-sandbox composition must gate both + // sentences on the platform instead (tracked in the pwsh-tool-and-executor + // Agent Note). return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + + 'In the same modes, programs cannot open named pipes, so a command that captures another ' + + 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default ' + + '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns ' + + 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: ' + + 'do not retry the command another way — escalate the exact command once or restructure it to ' + + 'avoid capturing output. ' + '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 ' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 1464ca14d3..91ad796ce5 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -552,13 +552,15 @@ describe('sandbox escalation through ctx.approval', () => { justification: 'the command needs workspace writes', } - it('advertises the sandbox fields, the escalation clause, and the ConstrainedLanguage contract', async () => { + it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => { const { ctx } = await setupSandboxed() const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! const properties = schema.parameters.properties as Record expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') + expect(schema.description).toContain('named pipes') + expect(schema.description).toContain('fails with EPERM') for (const args of [ { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' }, @@ -569,10 +571,11 @@ describe('sandbox escalation through ctx.approval', () => { } }) - it('the escalation fields and the ConstrainedLanguage clause stay out of sandbox-less compositions', async () => { + it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => { const { ctx } = await setup() const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! expect(schema.description).not.toContain('ConstrainedLanguage') + expect(schema.description).not.toContain('named pipes') expect(schema.description).not.toContain('sandbox_permissions') expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') }) From d7e19a0977cb5b10dc64b05cd2f17027b347a914 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 17:06:36 +0800 Subject: [PATCH 50/62] docs(sandbox): pin the translated confinement-runner heading anchor The upgraded md-links gate resolves same-file fragments against the zh file's own heading slugs; the translated heading needs the explicit a-id anchor the corpus convention uses for cross-language fragments. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 57ce89dba1..30b9f04979 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d -README.zh.md: e688218b2c12a529981433777a1163e3648c918a +README.zh.md: 11c0fdd4b7431a280ff656ae4d2bd63b81e9dd06 diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index e688218b2c..11c0fdd4b7 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -29,6 +29,8 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work 直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 + + ## 隔离 runner 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: From 428f84f710787a821f49f80a1cf69e383b9a22e5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 17:11:18 +0800 Subject: [PATCH 51/62] test(docs): split placed-image paths on either separator The placer tests receive absolute paths; on Windows the POSIX-only split kept the whole path as the basename. The windows-acl branch already carries this fix from an earlier merge-forward. --- scripts/project-doc-site.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index d3f00f3408..124656a4a1 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = absPath.split(/[\\/]/u).pop() ?? '' placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${absPath.split(/[\\/]/u).pop() ?? ''}`, })).toBe('![logo](./logo.svg#view)\n') }) From 63ad5d6d9810961eef240ccba2ea093b190b557a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 18:53:38 +0800 Subject: [PATCH 52/62] refactor(cli): drop the redundant existence pre-check in resolveWindowsShellLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadOverlayPatches already throws on a missing file (the caller named it, so absence is a misconfiguration) — the existsSync guard was a second fail-loud mechanism for the same miss with a prettier message. The loader's throw keeps the fail-loud contract the Windows-default note records. --- apps/cli/src/windows-shell.ts | 9 +++------ apps/cli/tests/windows-shell.spec.ts | 4 +++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 425699ef4f..c36dc0519c 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -10,7 +10,6 @@ * @module @deepseek-ai/dsh/windows-shell */ -import { existsSync } from 'node:fs' import { join } from 'node:path' import type { PatchOptions } from '@cordisjs/plugin-include' import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' @@ -36,8 +35,9 @@ export interface WindowsShellLayer { * @param binName - the diagnostic prefix on thrown errors (`dsh`). * @returns the pwsh layer on win32, else `undefined`. A custom profile that * mounts no base bundle is skipped (it owns its shell stack); a base - * bundle that ships no Windows shell patch fails loud — the shipped - * package always carries it, so a miss is a broken installation. + * bundle whose Windows shell patch is missing fails loud in + * {@link loadOverlayPatches} — the shipped package always carries it, so + * a miss is a broken installation. */ export function resolveWindowsShellLayer( platform: NodeJS.Platform, @@ -48,8 +48,5 @@ export function resolveWindowsShellLayer( const base = layers.find(layer => layer.packageName === BASE_BUNDLE) if (base === undefined) return undefined const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) - if (!existsSync(label)) { - throw new Error(`${binName}: ${BASE_BUNDLE} ships no ${WINDOWS_SHELL_PATCH_FILENAME}`) - } return { label, patches: loadOverlayPatches(binName, label) } } diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 80ba40cc34..fc562b7008 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -60,8 +60,10 @@ describe('resolveWindowsShellLayer', () => { it('fails loud when the base bundle ships no Windows shell patch', () => { const base = tempBase() mkdirSync(base, { recursive: true }) + // The overlay loader owns the fail-loud contract: the caller named this + // file, so its absence is a misconfiguration, not "no overlay". expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) - .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) + .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/) }) }) From 4cfe4366d7458513553bb1a4de346acdbbb81379 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 22:58:10 +0800 Subject: [PATCH 53/62] docs(note): drop the stage-numbering residual from the windows-default note The roadmap stage reference dated the decision record; the purge standard removes change-history narration and stage numbering from implemented notes. --- .../feature/2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-01-windows-pwsh-default.md | 2 +- .../implemented/feature/2026-08-01-windows-pwsh-default.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index af9f64208f..49b713f534 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: ea45040a0eb6f1325ed8f3a02a477d5dfd614696 -2026-08-01-windows-pwsh-default.zh.md: 41324afccdd775cac1629ed69217cff4237be091 +2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 +2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index ea45040a0e..f0da86e52b 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -16,7 +16,7 @@ Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, on - **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. - **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. -The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. +The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41324afccd..41a6429eab 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -16,7 +16,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 - **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 -原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 +pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 ## 备选方案 From 976020f2bb415d5475194b6f2577337212162b47 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 00:54:55 +0800 Subject: [PATCH 54/62] refactor(sandbox): derive the private temp dir; drop the acl-session record The durable sandbox/acl-session event carried a workspace binding that always equals the session cwd and a random temp path that only needed to be stable per session. Both are now derived: the temp subdirectory is sha256(session id + workspace), created exclusively and removed on provider dispose, so fork/resume semantics fall out of the derivation and the record, its fold/provision/tamper validation, the immediate flush kick, and the session-store dependency all disappear. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 25 -- docs/persistence-catalog.zh.md | 25 -- .../sandbox/sandbox-local/src/acl-session.ts | 126 ------- packages/sandbox/sandbox-local/src/index.ts | 172 +++++---- ...acl-session.spec.ts => acl-grants.spec.ts} | 356 ++++++++---------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 8 +- .../sandbox/sandbox-windows-acl/README.zh.md | 8 +- 12 files changed, 257 insertions(+), 487 deletions(-) delete mode 100644 packages/sandbox/sandbox-local/src/acl-session.ts rename packages/sandbox/sandbox-local/tests/{acl-session.spec.ts => acl-grants.spec.ts} (52%) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index f2514c3cb7..c9d678da0e 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: d6915cadb5817679749474c1046d1c715adc2b9b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 1533e39bf5c239730b6e027e5f0b345d4c6c42e4 +2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index d6915cadb5..7e8f229269 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 1533e39bf5..eeb346b228 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权、Everyone 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7a46a14aa5..e5dd4edde7 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: b07a02fcfca093c08acd206235e009d4d5fb9664 -persistence-catalog.zh.md: 6f75e4ae522b75fc48bf95df707a0bd0a990ea5c +persistence-catalog.md: a17cae015eaa107a900069de916dddb216b87ec7 +persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b07a02fcfc..a17cae015e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -488,31 +488,6 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ### `sandbox/*` -#### `sandbox/acl-session` — log-only - -```ts persistence-catalog -/** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ -'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string -} -``` - -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) - #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6f75e4ae52..3aef073ded 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -490,31 +490,6 @@ export type SessionEvent = { ### `sandbox/*` -#### `sandbox/acl-session` — log-only - -```ts persistence-catalog -/** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ -'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string -} -``` - -来源:[`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) - #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts deleted file mode 100644 index fb88eb10bb..0000000000 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * The windows-acl session write record — the DURABLE half of the seam's - * grant lifecycle. Each session owns exactly one record (its workspace - * binding plus one private temp subdirectory), stored as a log-only - * `sandbox/acl-session` event on the session log (the `sandbox/mode` - * precedent): replayable, never in the model transcript, and no external - * config store. The record carries NO SID: the write SID is the - * per-WORKSPACE identity derived from the workspace path - * (`workspaceWriteSid`) — deterministic across sessions and server - * restarts, so the workspace-root ACE materializes once per workspace per - * machine (the grant's exact-ACE skip makes every later provision O(1)) - * instead of once per session. The ACE half is server-lifetime state owned - * by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs - * revocable); the record survives restarts so a resumed session reuses the - * SAME private temp subdirectory and the same derived SID — re-granting - * idempotently merges into (or skips) the standing ACEs. The record is - * BOUND to its owning session id, so a fork (which copies the parent's - * events, record included) never inherits the parent's temp identity — it - * provisions a fresh one. The record's payload is durable input and is - * validated at the fold (well-formed workspace/temp paths); a - * matching-but-tampered record fails loud. - * - * @module dsh-sandbox-local/acl-session - */ - -import { randomBytes } from 'node:crypto' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ - 'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string - } - } -} - -/** The durable per-session record carried by one `sandbox/acl-session` event. */ -export interface AclSessionRecord { - /** The owning session id (binds the record against fork inheritance). */ - sessionId: SessionId - /** The workspace root the record was provisioned for (the write SID derives from it). */ - workspace: string - /** The session's private temp subdirectory. */ - tempDir: string -} - -/** - * The session's record: the last `sandbox/acl-session` event owned by it, or - * undefined (never confined / a fork). Durable-input validation: tampered - * workspace or temp path fails loud. @param events/@param sessionId/@returns - * as below. - * @param events - session events (other types skipped). - * @param sessionId - owning session (fork binding). - * @returns the last owned record, or undefined without one. - */ -export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type !== 'sandbox/acl-session') continue - const data = event.data - // Fork copies the parent's record: skip non-owned records (fork mints fresh). - if (data.sessionId !== sessionId) continue - if (typeof data.workspace !== 'string' || data.workspace.length === 0) { - throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) - } - if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`, - ) - } - return data - } - return undefined -} - -/** - * The session's private temp subdirectory name: `\dsh-<16 random hex>`. - * The name is RANDOM and persisted in the record — convergence across server - * restarts comes from the record (the same SID re-grants the same directory), - * not from any derivation an attacker (who knows the session id through - * `DSH_SESSION_ID`) could predict and pre-place. The provider creates it - * exclusively and rejects reparse points; OS temp hygiene may reclaim it — - * deliberately no GC here. - * @returns the private temp subdirectory path. - */ -export function sessionTempDir(): string { - return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`) -} - -/** - * Provision the record for a session that has none (its first Windows - * confined execution): the workspace binding plus the private temp - * subdirectory, appended as exactly one log-only `sandbox/acl-session` - * event — the provision IS its event, nothing mutates record state out of - * band. Fork (whose copied parent record is not its own) provisions a fresh - * record; resume replays the stored one. - * @param session - the session the record belongs to. - * @param workspaceRoot - the resolved policy root (the session's immutable cwd). - * @returns the provisioned record. - */ -export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { - const record: AclSessionRecord = { - sessionId: session.id, - workspace: workspaceRoot, - tempDir: sessionTempDir(), - } - session.append('sandbox/acl-session', record) - return record -} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 5ddf280063..e5d82c82d1 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,8 +7,8 @@ * * The windows-acl rung additionally owns the write grants: the write SID is * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), and one private temp subdirectory per session - * (durable record in the session log — see `./acl-session.ts`). The + * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per + * session (session id + workspace — nothing stored). The * workspace-root ACE materializes once per workspace per server lifetime * and STANDS (the cross-session reuse cache — the exact-ACE skip makes * every later provision O(1) instead of re-propagating the tree per @@ -19,8 +19,10 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, @@ -35,8 +37,6 @@ import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandb import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' -import { provisionAclSession, sessionAclRecord } from './acl-session.ts' -import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -110,6 +110,25 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): return probe.status === 0 } +/** + * The session's private temp subdirectory: `\dsh-<16 hex>`, derived + * from the session id and its workspace instead of stored. The same session + * and workspace always name the same directory — a resumed session + * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's + * different session id names a fresh one. The name is predictable to anyone + * who knows the session id (the confined command sees it as + * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and + * rejects reparse points: a pre-placed entry fails the first confined run + * loudly, and cannot redirect the grant onto a foreign object. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's private temp subdirectory path. + */ +export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { + const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') + return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) +} + /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -132,6 +151,8 @@ export interface SandboxInternals { windowsAclRunnerEntry?: string /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ probeWindowsAcl?: () => boolean + /** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */ + rmTempDir?: (path: string) => void } /** The chain's verdict: which runner confines, and how completely it enforces. */ @@ -257,12 +278,12 @@ export class LocalSandboxProvider extends SandboxProvider { * Server-lifetime write grants (windows-acl rung): the STANDING * workspace-root grant per workspace (its ACE is the cross-session reuse * cache and outlives the provider — never revoked) and the REVOCABLE - * private-temp grant per session (revoked on provider dispose); the - * durable half (workspace binding + private temp dir) lives in the - * session log (`./acl-session.ts`). + * private-temp grant per session (revoked on provider dispose). */ private readonly workspaceGrants = new Map() private readonly tempGrants = new Map() + /** Session id → the private temp directory this provider created (removed on dispose). */ + private readonly tempDirs = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -335,18 +356,15 @@ export class LocalSandboxProvider extends SandboxProvider { } /** - * The windows-acl runner argv for one policy. With a calling session - * (the policy's `sessionId`), the session's durable record is folded from - * the session log (provisioned on first use), its ACEs materialized once - * per server lifetime, and the runner receives `--write-sid` plus the - * session's PRIVATE temp subdirectory — it grants nothing and revokes - * nothing. A fresh provision kicks an IMMEDIATE persistence flush right - * after the append (no write-behind debounce delay), narrowing the - * crash-and-lose-record window to the flush latency itself — the residual - * is documented in the README (the spawn seams are synchronous, so no - * await barrier exists between record and ACEs). Agentless calls (no - * session) pass no SID: the runner self-manages per-call grants on the - * ambient temp root. + * The windows-acl runner argv for one policy. With a calling session (the + * policy's `sessionId`), the write grants are materialized once per server + * lifetime — the standing workspace-root grant per workspace and the + * revocable private-temp grant per session — and the runner receives + * `--write-sid` (the workspace-derived identity; its presence marks the + * seam-managed DACL contract) plus, under workspace-write, the session's + * PRIVATE temp subdirectory (derived from session id + workspace) — it + * grants nothing and revokes nothing. Agentless calls pass the ambient + * temp root and no `--write-sid`: the runner self-manages its DACLs. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ @@ -360,8 +378,7 @@ export class LocalSandboxProvider extends SandboxProvider { '--mode', policy.mode, ] } - const record = this.aclSessionRecord(sessionId, policy.workspaceRoot) - this.materializeAclGrant(record, policy.mode) + this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, @@ -370,79 +387,40 @@ export class LocalSandboxProvider extends SandboxProvider { // runs pass the ambient temp root — the runner validates it exists // but grants nothing. The derived write SID is the per-workspace // identity; the flag's presence marks the seam-managed DACL contract. - '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), + '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), '--mode', policy.mode, - '--write-sid', workspaceWriteSid(record.workspace), + '--write-sid', workspaceWriteSid(policy.workspaceRoot), ] } /** - * Fold (or provision) the calling session's durable windows-acl record. - * The provision appends exactly one log-only `sandbox/acl-session` event - * to the session log and kicks an immediate persistence flush (the - * write-behind coordinator's bounded window would otherwise delay the - * record's durability past its ACE materialization); the record's - * workspace must equal the policy root — both derive from the session's - * immutable cwd, so a mismatch is a corrupted composition and fails loud. - * @param sessionId - the policy's calling-session identity. - * @param workspaceRoot - the resolved policy root. - * @returns the session's record. - */ - private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord { - const store = this.ctx.get('sessions') - if (store === undefined) { - throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') - } - const session = store.get(sessionId) - if (session === undefined) { - throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) - } - const existing = sessionAclRecord(session.events, sessionId) - if (existing !== undefined) { - if (existing.workspace !== workspaceRoot) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} ` - + `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`, - ) - } - return existing - } - const record = provisionAclSession(session, workspaceRoot) - // Immediate durability kick: the append is write-behind (bounded - // coordinator window); flush now so the record is durable as close to - // its ACE materialization as the synchronous confine seam allows. The - // residual window (a crash inside the flush latency) strands the - // private temp directory unrecorded — documented in the README. - void store.flush(session) - return record - } - - /** - * Materialize the record's ACEs once per server lifetime: lazily at the - * session's first confined execution, reused for every later call (the map - * hits are the whole call). The write SID is the per-workspace identity - * derived from the record's workspace. Workspace-write grants the - * workspace root STANDING (the ACE outlives every session — the reuse - * cache) and the private temp subdirectory REVOCABLY — created here - * EXCLUSIVELY (the name is random and unguessable, a pre-existing entry - * throws EEXIST, and a reparse point is rejected, so the grant never - * lands on an attacker-placed object); read-only materializes NOTHING — - * its token alone restricts every write, and the standing grant from an + * Materialize the session's ACEs once per server lifetime: lazily at its + * first confined execution, reused for every later call (the map hits are + * the whole call). The write SID is the per-workspace identity derived + * from the workspace. Workspace-write grants the workspace root STANDING + * (the ACE outlives every session — the reuse cache) and the session's + * private temp subdirectory REVOCABLY — the directory is derived from + * session id + workspace, created here EXCLUSIVELY (a pre-existing entry + * or a reparse point fails the first confined run loudly, so the grant + * never lands on a foreign object); read-only materializes NOTHING — its + * token alone restricts every write, and the standing grant from an * earlier workspace-write period is KEPT through a downgrade (never * revoked): the read-only restricted token carries no write SID (the * read-only list), so the ACE is inert there, while the map hit keeps the * re-upgrade free of re-propagation. Fail-closed: a half-materialized * temp grant is revoked before the error propagates. - * @param record - the session's durable record. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. * @param mode - the policy mode (grants exist only under workspace-write). */ - private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { if (mode === 'read-only') return - const writeSid = workspaceWriteSid(record.workspace) - if (!this.workspaceGrants.has(record.workspace)) { + const writeSid = workspaceWriteSid(workspaceRoot) + const tempDir = sessionTempDir(sessionId, workspaceRoot) + if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) try { - grant.add(record.workspace, true) + grant.add(workspaceRoot, true) } catch (error) { // Free the SID; a standing ACE (if the apply succeeded before a // post-apply throw) is the intended end state, not an error @@ -454,17 +432,23 @@ export class LocalSandboxProvider extends SandboxProvider { } throw error } - this.workspaceGrants.set(record.workspace, grant) + this.workspaceGrants.set(workspaceRoot, grant) } - if (this.tempGrants.has(record.sessionId)) return + if (this.tempGrants.has(sessionId)) return const grant = AclWriteGrant.create(writeSid) + // The directory is removed again in the catch only when THIS confine + // created it — a pre-existing entry (EEXIST) is a foreign object and is + // never deleted. + let created = false try { // Exclusive creation (no `recursive`): a pre-existing entry OR a // reparse point both fail EEXIST — the grant never lands on a foreign // object. - mkdirSync(record.tempDir) - grant.add(record.tempDir) + mkdirSync(tempDir) + created = true + grant.add(tempDir) } catch (error) { + if (created) rmSync(tempDir, { recursive: true, force: true }) // Revoke whatever stands and free the SID — never leave a half-grant // behind a failed confine (the runner never runs). try { @@ -474,15 +458,18 @@ export class LocalSandboxProvider extends SandboxProvider { } throw error } - this.tempGrants.set(record.sessionId, grant) + this.tempGrants.set(sessionId, grant) + this.tempDirs.set(sessionId, tempDir) } /** * Dispose every write grant (provider dispose): the revocable temp ACEs - * are revoked and every SID allocation freed; the standing workspace ACEs + * are revoked, the private temp directories this provider created are + * removed, and every SID allocation is freed; the standing workspace ACEs * stay (the reuse cache). Cleanup failures are reported, not thrown: - * cordis teardown must not be aborted by grant cleanup, and the durable - * records make a missed revocation self-healing on the next resume. + * cordis teardown must not be aborted by grant cleanup. A crash skips all + * of it — the next resume then fails loudly at the exclusive creation and + * OS temp hygiene (or manual removal) recovers. */ private revokeAclGrants(): void { if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return @@ -494,8 +481,17 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } + const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => rmSync(dir, { recursive: true, force: true })) + for (const dir of this.tempDirs.values()) { + try { + rmTempDir(dir) + } catch (error) { + failures.push(error) + } + } this.workspaceGrants.clear() this.tempGrants.clear() + this.tempDirs.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts similarity index 52% rename from packages/sandbox/sandbox-local/tests/acl-session.spec.ts rename to packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index c6ee33f3e8..ca2410c317 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,11 +1,10 @@ /** - * windows-acl write grants: the DURABLE record (log-event fold/provision with - * ownership binding + tamper validation) plus the SERVER-LIFETIME ACE - * materialization (standing workspace grant per workspace, revocable temp - * grant per session), through the REAL LocalSandboxProvider.confine() with a - * real session store. Win32 surface mocked at the package boundary (the - * workspace-derived SID mocked to a constant); the real-FFI grant behavior - * lives in sandbox-windows-acl's win32 tests. + * windows-acl write grants: the SERVER-LIFETIME ACE materialization + * (standing workspace grant per workspace, revocable private-temp grant per + * session) plus the derived private-temp identity, through the REAL + * LocalSandboxProvider.confine(). Win32 surface mocked at the package + * boundary (the workspace-derived SID mocked to a constant); the real-FFI + * grant behavior lives in sandbox-windows-acl's win32 tests. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' @@ -14,15 +13,15 @@ import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' -import { sessionTempDir } from '../src/acl-session.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, + /** Restricts {@link addFailure} to this path (undefined = every add throws). */ + addFailurePath: undefined as string | undefined, disposeFailure: undefined as Error | undefined, })) @@ -39,7 +38,9 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { - if (mockState.addFailure !== undefined) throw mockState.addFailure + if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + throw mockState.addFailure + } this.added.push({ path, standing }) } dispose(): void { @@ -53,28 +54,17 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { /** The workspace-derived write SID the mock pins for every workspace. */ const DERIVED_SID = 'S-1-4-42-42' -/** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { - return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } -} - async function setup() { const ctx = new Context() - await ctx.plugin(SessionStore) const fiber = await ctx.plugin(LocalSandboxProvider, {}) const sandbox = ctx.sandbox as LocalSandboxProvider sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } return { ctx, sandbox, fiber } } -/** A workspace root the policy, the record, and the session cwd all share. */ +/** A workspace root the policy carries. */ function workspaceRoot(): string { - return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) -} - -/** A well-shaped private temp path under the host temp root (never created). */ -function shapedTempPath(): string { - return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) + return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) } describe('windows-acl write grants (LocalSandboxProvider)', () => { @@ -83,6 +73,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined + mockState.addFailurePath = undefined mockState.disposeFailure = undefined }) @@ -90,21 +81,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => { + it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { try { - const { ctx, sandbox, fiber } = await setup() + const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) + const tempDir = sessionTempDir(SessionId('sess-1'), ws) + scratch.push(tempDir) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(confined.argv).toContain('--write-sid') - expect(confined.argv).toContain(DERIVED_SID) - expect(confined.argv).toContain('workspace-write') + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'pwsh', '/Command', 'x', + ]) expect(mockState.grants).toHaveLength(2) - const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir - scratch.push(tempDir) expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked @@ -116,12 +112,10 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { disposed: false, }) expect(existsSync(tempDir)).toBe(true) // created exclusively - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) // Reuse: the second confine is the map hits. sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(mockState.grants).toHaveLength(2) - expect(session.events).toHaveLength(1) await fiber.dispose() // dispose() runs on BOTH grants: the standing workspace ACE is left in @@ -135,66 +129,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const tempDir = sessionTempDir(SessionId('sess-switch'), ws) + scratch.push(tempDir) const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } - // read-only first: record rides along, nothing materialized, ambient temp. + // read-only first: nothing materialized, ambient temp. const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toContain('--write-sid') - expect(confinedRo.argv).toContain(tmpdir()) - expect(mockState.grants).toHaveLength(0) - const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string } - scratch.push(record.tempDir) - expect(existsSync(record.tempDir)).toBe(false) - - // Upgrade: first workspace-write materializes with the derived SID. - const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', record.tempDir, - '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: record.tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(record.tempDir)).toBe(true) - - // Reuse: map hits. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(2) - - // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). - sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - } finally { - cleanup() - } - }) - - it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') } - - const confined = sandbox.confine(['true'], policy) - expect(confined.argv).toEqual([ + expect(confinedRo.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing @@ -204,90 +149,89 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + expect(existsSync(tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the derived SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) + expect(existsSync(tempDir)).toBe(true) + + // Reuse: map hits. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(2) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) } finally { cleanup() } }) - it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => { + it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { try { const ws = workspaceRoot() scratch.push(ws) - const tempDir = shapedTempPath() - const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir } - scratch.push(tempDir) - const first = await setup() - const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } - const confined = first.sandbox.confine(['true'], policy) - expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace + const firstConfined = first.sandbox.confine(['true'], policy) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] }) - // Replay IS the state: nothing appended. - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - expect(session.events).toHaveLength(2) + + // Clean restart: dispose revokes the temp ACE and removes the private + // temp directory, so the fresh provider's exclusive creation succeeds. + await first.fiber.dispose() + mockState.grants = [] + const second = await setup() + const secondConfined = second.sandbox.confine(['true'], policy) + expect(secondConfined.argv).toEqual(firstConfined.argv) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], + }) + await second.fiber.dispose() } finally { cleanup() } }) - it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => { + it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const parentTemp = shapedTempPath() - const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } + const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + + sandbox.confine(['true'], parentPolicy) + const parentTemp = sessionTempDir(SessionId('parent'), ws) scratch.push(parentTemp) - // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. - const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) + sandbox.confine(['true'], childPolicy) + const childTemp = sessionTempDir(SessionId('child'), ws) + scratch.push(childTemp) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) // Fresh temp identity, NOT the parent's (the workspace SID is shared by - // derivation — the workspace is the same). - const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir + // derivation — the workspace is the same, so the standing grant is the + // map hit and only the child's temp grant joins). expect(childTemp).not.toBe(parentTemp) - expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) - expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh - } finally { - cleanup() - } - }) - - it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - - // tempDir outside the host temp root. - const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } - ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) - const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } - expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) - - // Non-string durable fields (a corrupted/tampered JSONL payload): the - // typeof guards fail loud before any string operation runs. There is - // NO stored SID to tamper with — the write SID is derived from the - // workspace path, so the old "SID rewritten to Everyone" attack - // surface does not exist. - const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ - { id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, - ] - for (const c of cases) { - ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId(c.id) } - expect(() => sandbox.confine(['true'], policy), c.id).toThrow(c.expect) - } - expect(mockState.grants).toHaveLength(0) + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) } finally { cleanup() } @@ -295,16 +239,14 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. - const preexisting = shapedTempPath() + const preexisting = sessionTempDir(SessionId('preexisting'), ws) mkdirSync(preexisting) scratch.push(preexisting) - const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } - ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) // The standing workspace grant is the intended end state and stays; the @@ -316,11 +258,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // Reparse point: same EEXIST (exclusive mkdir never follows links). const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) scratch.push(target) - const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name + const linkPath = sessionTempDir(SessionId('reparse'), ws) symlinkSync(target, linkPath) scratch.push(linkPath) - const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } - ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) // Same workspace as the preexisting case: the standing workspace grant @@ -333,11 +273,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // fails — the temp cleanup AggregateError propagates. mockState.grants = [] mockState.disposeFailure = new Error('temp cleanup exploded') - const dupTemp = shapedTempPath().replace(/abab$/, 'efef') + const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) mkdirSync(dupTemp) scratch.push(dupTemp) - const dupRecord = { sessionId: SessionId('temp-cleanup-fail'), workspace: ws, tempDir: dupTemp } - ctx.sessions.create(SessionId('temp-cleanup-fail'), { seed: [recordEvent(dupRecord)], meta: { cwd: ws } }) const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) @@ -346,49 +284,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } - ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } - expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) - expect(mockState.grants).toHaveLength(0) - } finally { - cleanup() - } - }) - - it('fails loud without the session store, and when the policy names a session the store does not hold', async () => { - try { - const bare = new Context() - await bare.plugin(LocalSandboxProvider, {}) - const sandbox = bare.sandbox as LocalSandboxProvider - sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') } - expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) - - const { sandbox: withStore } = await setup() - expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/) - } finally { - cleanup() - } - }) - it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') - scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) @@ -402,7 +308,29 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => { + it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } + + // The workspace grant succeeds; only the TEMP grant's add throws (the + // path-targeted failure keeps the workspace branch intact). + mockState.addFailurePath = tempDir + mockState.addFailure = new Error('temp add exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') + expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays + expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { try { const { sandbox, fiber } = await setup() const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } @@ -427,10 +355,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) - scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(2) mockState.disposeFailure = new Error('revoke exploded') @@ -444,11 +371,34 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => { - const a = basename(sessionTempDir()) - const b = basename(sessionTempDir()) - expect(a).not.toBe(b) - expect(a).toMatch(/^dsh-[0-9a-f]{16}$/) - expect(b).toMatch(/^dsh-[0-9a-f]{16}$/) + it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(2) + + sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + // Both grants dispose cleanly; only the directory removal fails. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + } finally { + cleanup() + } + }) + + it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { + const base = sessionTempDir(SessionId('sess-a'), '/ws/a') + expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) + expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session + expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace + // The separator prevents id/workspace collisions from merging inputs. + expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 30b9f04979..c53e5cf6fc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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-windows-acl/README.md -README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d -README.zh.md: 11c0fdd4b7431a280ff656ae4d2bd63b81e9dd06 +README.md: b13160f7490878143c719ca617936b74ffd298af +README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 6f649f5dcc..b13160f749 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -39,7 +39,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam still provisions ONE log-only `sandbox/acl-session` event per session (bound to the owning session id, validated at the fold) carrying the session's workspace binding and PRIVATE temp subdirectory: a resumed session replays the same temp dir, a fork mints a fresh one. The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. @@ -65,8 +65,8 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. -- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -85,7 +85,7 @@ None directly; the denial surface belongs to the tool layer. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. -- **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. +- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 11c0fdd4b7..9895449f6f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -41,7 +41,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID(先前每会话随机 SID 及其篡改面已移除)。seam 仍会为每个会话只供给一条仅作日志记录的 `sandbox/acl-session` 事件(绑定其所属会话 id,在 fold 处校验),携带会话的工作区绑定与**私有**临时子目录:恢复的会话回放同一个临时目录,fork 则铸造一个新的。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 @@ -67,8 +67,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 位随机 hex>`,独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 -- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 +- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 ## Model Experience @@ -87,7 +87,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 - **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 -- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 +- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 - **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 From 0306aefaa9e04318f13a216f659943dfa466bd61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:12:36 +0800 Subject: [PATCH 55/62] test(web): refresh steer-all mid golden against merged master UI --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 65f22f2140..20d799ca79 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,11 +11,6 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt -- text: Running -- button "Think": - - img - - img - - text: Think - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": From 85ebb03c0018bdd4a3c4d577818d9c4ac007f00f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:30:33 +0800 Subject: [PATCH 56/62] test(web): wait for the reasoning row before the steer-all mid snapshot The mid golden was racing the reasoning block's stream: captureStableAria could freeze on the pre-render gap between steering acceptance and the assistant step, pinning a snapshot without the Think row. Wait for the [data-variant=think] row so the golden captures the complete assistant step. --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 +++++ apps/web/tests/steering.e2e.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 20d799ca79..998ee98129 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,6 +11,11 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 182f892d54..8a09582b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -353,6 +353,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { { timeout: 10_000 }, ).toBe(2) expect(await page.locator('[data-queue-dock]').count()).toBe(0) + // The reasoning row streams independently of the steering handoff; wait + // for it so the mid snapshot pins the assistant step, not the pre-render + // gap a fast machine can catch between steering acceptance and the block. + await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 }) const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) From ad54135d4398406f81af394ca7217052453a7708 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 12:30:39 +0800 Subject: [PATCH 57/62] fix(sandbox-local): block-body the default rmTempDir fallback The arrow shorthand implicitly returns rmSync's void, which the no-confusing-void-expression rule forbids; the block body keeps the fallback without the violation. --- packages/sandbox/sandbox-local/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index e5d82c82d1..42a150b855 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -481,7 +481,7 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } - const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => rmSync(dir, { recursive: true, force: true })) + const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) for (const dir of this.tempDirs.values()) { try { rmTempDir(dir) From 0b9f4844c23697674596e6d7bef058ac3ccb99f6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 12:30:46 +0800 Subject: [PATCH 58/62] fix(config): drop the obsolete lsp-local coverage exclusions Master's native Windows coverage lane now covers the LSP sources, and its ci-workflow spec asserts the exclusion paths are absent from vitest.config.ts; keeping them (as carried over in the merge) fails both the coverage and the native Windows lanes. The sandbox-windows-acl exclusion stays: that package is win32-only and the Linux lane cannot cover it. --- vitest.config.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 3907d527e5..68bfafec8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,16 +37,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - ] - : [] - // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -238,7 +228,6 @@ export default defineConfig({ 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], From a7fc81d745ad178194be6c905bb1a6b4c19cd624 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 15:13:12 +0800 Subject: [PATCH 59/62] docs: replace front door terminology --- .agents/notes/README.i18n.yaml | 2 +- .agents/notes/README.md | 2 +- ...-15-llm-model-catalog-and-acp-selection.i18n.yaml | 4 ++-- ...2026-07-15-llm-model-catalog-and-acp-selection.md | 8 ++++---- ...6-07-15-llm-model-catalog-and-acp-selection.zh.md | 8 ++++---- ...026-07-19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- .../2026-07-19-gui-layering-and-rpc-protocol.md | 6 +++--- .../2026-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +++--- ...config-tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...24-web-config-tree-boot-and-transport-layering.md | 8 ++++---- ...web-config-tree-boot-and-transport-layering.zh.md | 8 ++++---- .../2026-08-05-profile-plugin-bundles.i18n.yaml | 4 ++-- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...08-09-headless-direct-core-entry-point.i18n.yaml} | 6 +++--- ...> 2026-08-09-headless-direct-core-entry-point.md} | 12 ++++++------ ...026-08-09-headless-direct-core-entry-point.zh.md} | 8 ++++---- ...026-07-20-error-cause-chain-diagnostics.i18n.yaml | 4 ++-- .../2026-07-20-error-cause-chain-diagnostics.md | 2 +- .../2026-07-20-error-cause-chain-diagnostics.zh.md | 2 +- .../feature/2026-06-24-workspace-context.i18n.yaml | 2 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../feature/2026-07-16-harness-level-loop.md | 2 +- .../feature/2026-07-16-harness-level-loop.zh.md | 2 +- .../2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../feature/2026-07-16-persistent-pty-sessions.md | 2 +- .../feature/2026-07-16-persistent-pty-sessions.zh.md | 2 +- .../feature/2026-07-19-human-goal-command.i18n.yaml | 4 ++-- .../feature/2026-07-19-human-goal-command.md | 2 +- .../feature/2026-07-19-human-goal-command.zh.md | 2 +- .../2026-07-19-plugin-command-registration.i18n.yaml | 4 ++-- .../2026-07-19-plugin-command-registration.md | 6 +++--- .../2026-07-19-plugin-command-registration.zh.md | 6 +++--- .../2026-07-21-local-instruction-overlay.i18n.yaml | 2 +- .../feature/2026-07-21-local-instruction-overlay.md | 2 +- .../2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../2026-07-24-web-session-model-selector.zh.md | 4 ++-- ...-08-07-default-model-follows-the-picker.i18n.yaml | 4 ++-- .../2026-08-07-default-model-follows-the-picker.md | 4 ++-- ...2026-08-07-default-model-follows-the-picker.zh.md | 4 ++-- .../2026-08-08-dsh-run-headless-command.i18n.yaml | 4 ++-- .../feature/2026-08-08-dsh-run-headless-command.md | 2 +- .../2026-08-08-dsh-run-headless-command.zh.md | 2 +- ...26-08-08-user-explicit-skill-invocation.i18n.yaml | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.md | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.zh.md | 4 ++-- .../2026-07-05-uniform-agent-note-format.i18n.yaml | 2 +- .../process/2026-07-05-uniform-agent-note-format.md | 2 +- ...07-19-remove-generated-agent-note-index.i18n.yaml | 2 +- .../2026-07-19-remove-generated-agent-note-index.md | 2 +- .../2026-07-22-product-first-root-readme.i18n.yaml | 2 +- .../process/2026-07-22-product-first-root-readme.md | 4 ++-- ...-08-03-package-anchored-subsystem-pages.i18n.yaml | 4 ++-- .../2026-08-03-package-anchored-subsystem-pages.md | 2 +- ...2026-08-03-package-anchored-subsystem-pages.zh.md | 2 +- ...2026-07-23-acp-automation-only-protocol.i18n.yaml | 2 +- .../2026-07-23-acp-automation-only-protocol.md | 2 +- .../2026-08-04-remove-tui-package.i18n.yaml | 2 +- .../simplification/2026-08-04-remove-tui-package.md | 4 ++-- .../2026-08-08-remove-cli-demo.i18n.yaml | 2 +- .../simplification/2026-08-08-remove-cli-demo.md | 2 +- apps/cli/src/profile-boot.ts | 2 +- docs/architecture.i18n.yaml | 2 +- docs/architecture.md | 4 ++-- docs/capability-seams.i18n.yaml | 4 ++-- docs/capability-seams.md | 4 ++-- docs/capability-seams.zh.md | 4 ++-- docs/cordis-tutorial/07-into-the-harness.i18n.yaml | 4 ++-- docs/cordis-tutorial/07-into-the-harness.md | 2 +- docs/cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- examples/acp-agent/composition.md | 2 +- examples/headless-agent/README.i18n.yaml | 2 +- examples/headless-agent/README.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 4 ++-- packages/core/README.i18n.yaml | 2 +- packages/core/README.md | 4 ++-- packages/core/agent-default-model/README.i18n.yaml | 2 +- packages/core/agent-default-model/README.md | 6 +++--- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-default-model/src/index.ts | 2 +- packages/core/agent/src/model-selection.ts | 4 ++-- packages/examples/README.i18n.yaml | 4 ++-- packages/examples/README.md | 6 +++--- packages/examples/README.zh.md | 4 ++-- packages/examples/acp-demo/README.i18n.yaml | 4 ++-- packages/examples/acp-demo/README.md | 2 +- packages/examples/acp-demo/README.zh.md | 2 +- packages/examples/acp-demo/src/index.ts | 4 ++-- packages/examples/agent-spine-demo/README.i18n.yaml | 4 ++-- packages/examples/agent-spine-demo/README.md | 12 ++++++------ packages/examples/agent-spine-demo/README.zh.md | 12 ++++++------ packages/examples/agent-spine-demo/src/index.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +++--- packages/feedback/command-feedback/README.i18n.yaml | 2 +- packages/feedback/command-feedback/README.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 4 ++-- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/index.ts | 2 +- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.md | 2 +- packages/scaffold/helper/src/features/builtin/app.ts | 6 +++--- packages/scaffold/helper/src/features/feature.ts | 4 ++-- packages/scaffold/helper/src/project/types.ts | 2 +- packages/scaffold/helper/tests/project.spec.ts | 2 +- .../self-modification/tool-cordis/src/api-catalog.ts | 2 +- .../self-modification/tool-cordis/src/sandbox.ts | 2 +- scripts/gen-doc-graphs.ts | 6 +++--- 121 files changed, 211 insertions(+), 211 deletions(-) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.i18n.yaml => 2026-08-09-headless-direct-core-entry-point.i18n.yaml} (55%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.md => 2026-08-09-headless-direct-core-entry-point.md} (76%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.zh.md => 2026-08-09-headless-direct-core-entry-point.zh.md} (94%) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index b33a36665d..56eeb36b4d 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 +README.md: d3a8943a78238d974d54028e38b773e932429b0e README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 3cfbb51547..d3a8943a78 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 02d531f642..0c81565bdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.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/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 77f8e379e2b07ecf4e67fa7197752543cfedd6dd -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: ce4a04a66e345834bc2b16b743be89dc7a0b9424 +2026-07-15-llm-model-catalog-and-acp-selection.md: bfd17c73b01319c10d5dc03333b3c726db5d6f33 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aeddada5591bb2da2c0861acc368516eff148172 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 77f8e379e2..bfd17c73b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -26,17 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch `dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. -### Per-session selection in the front door +### Per-session selection in the front end -A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. -The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. +The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index ce4a04a66e..aeddada559 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -26,17 +26,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 `dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 -### 前门内的会话级选择 +### 前端内的会话级选择 -选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 87c65807b8..d47c50cdb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7997a682c8745f7b3d0a9721acfa9355603f9b2e -2026-07-19-gui-layering-and-rpc-protocol.zh.md: cb36b5cb725128e1c5067e5e69e52aa851668e52 +2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7997a682c8..705b0df5fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. +The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. ## Message protocol @@ -242,7 +242,7 @@ Every client shape consumes one contract: adding a unary method is a five-step m |---|---| | Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index cb36b5cb72..b28b08b4b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(前门插件)` 挂载,不套 fetch。 +现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 ## 消息协议 @@ -240,7 +240,7 @@ export type ResponseValue = |---|---| | 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index ff517555db..e7fd7799f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 496499a691dbca012e5e953cbb6eb1d0bf25b635 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c4bed7b730cf37e1d90750f5c93fbccb920ced21 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 92ec665acc745e61f656bd0e57454ad266b722f9 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7cd96ad9e52c19a005e6bff356dc5159ad3a31bc diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 496499a691..92ec665acc 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,23 +16,23 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- [Headless is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. +- [Headless is a direct core entry point](2026-08-09-headless-direct-core-entry-point.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors | +| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | | Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c4bed7b730..7cd96ad9e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,23 +16,23 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 +- [Headless 是直接 core 入口](2026-08-09-headless-direct-core-entry-point.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| -| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 | +| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | | 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index fcac71abf4..938e802716 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 8b5ab0c99282f6868fc3f70781e618af9a317c09 -2026-08-05-profile-plugin-bundles.zh.md: 2a685d68b3de9210488e26f8e6dd93dfc07f956c +2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b +2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 8b5ab0c992..2924b3cb44 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core front door](2026-08-09-headless-direct-core-front-door.md) owns the headless composition contract. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 2a685d68b3..b228703401 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 前门](2026-08-09-headless-direct-core-front-door.md)负责 headless 组合约定。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 [`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 20b200713a..b851627050 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md -2026-08-09-headless-direct-core-front-door.md: f4604329a9276448a0021bb749b09e8c1b82e3c1 -2026-08-09-headless-direct-core-front-door.zh.md: aaa1289894bf3c69b39aa863493dffdc3437ad01 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a +2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md similarity index 76% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index f4604329a9..49afe2993d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -1,22 +1,22 @@ -# Agent Note: headless is a direct core front door +# Agent Note: headless is a direct core entry point Status: implemented -English | [中文](2026-08-09-headless-direct-core-front-door.zh.md) +English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. -The direct front door still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. +The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. -`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. +`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. @@ -31,7 +31,7 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot front door has no client boundary. | +| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | | Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md similarity index 94% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index aaa1289894..73c1cbe5ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -2,13 +2,13 @@ Status: implemented -[English](2026-08-09-headless-direct-core-front-door.md) | 中文 +[English](2026-08-09-headless-direct-core-entry-point.md) | 中文 ## 问题 `headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 -直接前门仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 +直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 @@ -31,7 +31,7 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性前门没有客户端边界。 | +| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | | 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index c9581fcbfb..9d94121626 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.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/bug-fix/2026-07-20-error-cause-chain-diagnostics.md -2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586 -2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594 +2026-07-20-error-cause-chain-diagnostics.md: b80dd08d79a57738a6eef2f8638b0336ca80d4bf +2026-07-20-error-cause-chain-diagnostics.zh.md: 914e983d7ea81eef8bca8fd5aa3791f2f44d4080 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 32716b5a68..b80dd08d79 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -9,7 +9,7 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md) A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: 1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line. -2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. +2. The readline entry point (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 9911c32b6d..914e983d7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -9,7 +9,7 @@ Status: implemented TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: 1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 -2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 +2. readline 入口(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 32c80847bb..e60442bb37 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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-06-24-workspace-context.md -2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885 +2026-06-24-workspace-context.md: e7a3724847b9dc8cfad11e87b2c96a3ef442bcba 2026-06-24-workspace-context.zh.md: 39c3f52da10b7299301d10bd8b78330cbae8e19c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 53a580aee5..e7a3724847 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. +Workspace guidance is isolated per session and shared by the demo entry points, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index b7181bfba1..af11b3f3f9 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.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-16-harness-level-loop.md -2026-07-16-harness-level-loop.md: e84a5738a55988d3b968eac829d9daaf10d7e304 -2026-07-16-harness-level-loop.zh.md: d7cda56d66d4456ac5c3c7d55bb238e85fcacbc5 +2026-07-16-harness-level-loop.md: e5dd94fbf76e27f0843666f29622969635ef2fc3 +2026-07-16-harness-level-loop.zh.md: 773b5ff1f4b07206d0d817c29774435ce24407b4 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index e84a5738a5..e5dd94fbf7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Code requires a direct human message in the current live root-agent turn; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. +TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC entry points do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. ### Fresh-agent Ralph execution diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index d7cda56d66..773b5ff1f4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -70,7 +70,7 @@ Goal Round 驱动器为每个特定的实时 agent 至多拥有一个待定预 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。代码要求当前实时根 agent Turn 中有一条人类直接发送的消息;语义解释仍是模型判断。自治目标 Round 可以为确切的当前 Goal Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 +TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 运行入口不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 146601b28d..32fa522443 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: f27d799574e61d92123c9ca4e31c5c2a9d3229b4 -2026-07-16-persistent-pty-sessions.zh.md: 0c23619faf00794c2c4e7b3f85ef961faddd99e0 +2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 +2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index f27d799574..fb9cd06bad 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -152,7 +152,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. -**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. +**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current long-lived entry points already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 0c23619faf..55a5848c1a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -152,7 +152,7 @@ plugins: **包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 -**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 +**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前长驻的运行入口已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 5b39ad725e..b45809724c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.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-19-human-goal-command.md -2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 -2026-07-19-human-goal-command.zh.md: 89d29497abfc758ff8373d272aa8620ca1bcfcc2 +2026-07-19-human-goal-command.md: d68e4025a4d37d07211f15ddfc6069bc7c637124 +2026-07-19-human-goal-command.zh.md: dbda35c731ccdc7aff2d4213a3df8b78070319df diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 5fdd80f742..d68e4025a4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -68,5 +68,5 @@ The producer suite uses the real command registry, goal service, agent registry, - The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. - `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. - TUI renders portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. -- The ACP automation server, headless CLI, and JSON-RPC front doors do not consume the command registry. +- The ACP automation server, headless CLI, and JSON-RPC entry points do not consume the command registry. - The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 89d29497ab..dbda35c731 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -68,5 +68,5 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 - 可移植命令约定没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 - `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 - TUI 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC 前端不消费命令注册表。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC 运行入口不消费命令注册表。 - 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离约定的独立策略层。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 26dde53462..83e3319f6d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.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-19-plugin-command-registration.md -2026-07-19-plugin-command-registration.md: c8f0f2772a41948e9eb257a16f40194518c568f9 -2026-07-19-plugin-command-registration.zh.md: a8f7a3d1e7947aeb41344a72106cee55d249010c +2026-07-19-plugin-command-registration.md: 76feba84492bd6b245246a6f1fb1e8f68d555684 +2026-07-19-plugin-command-registration.zh.md: e89cb79ba1e15dfefa527fe7bc37c09c3449f5b4 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index c8f0f2772a..76feba8449 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -12,7 +12,7 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ## Decision -`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front door; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. +`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front end; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. ### Registry contract @@ -50,7 +50,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing the TUI. - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. -- **Put the registry in the core agent spine** — rejected because UI-less front doors do not consume it, while TUI can compose it explicitly. +- **Put the registry in the core agent spine** — rejected because UI-less entry points do not consume it, while TUI can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. - **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. @@ -68,4 +68,4 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - Input metadata is limited to an unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later registry or consumer extension. - Generic command output is live-only and is not reconstructed after TUI restart. - Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. -- The ACP automation server, headless CLI, and JSON-RPC SDK front doors do not expose the command plane; only TUI consumes it. +- The ACP automation server, headless CLI, and JSON-RPC SDK entry points do not expose the command plane; only TUI consumes it. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index a8f7a3d1e7..e89cb79ba1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -12,7 +12,7 @@ TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派 ## 决策 -位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的入口旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 +位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的前端旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 ### 注册表约定 @@ -50,7 +50,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改 TUI。 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 -- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 前端不消费它,而 TUI 可以显式组合它。 +- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 运行入口不消费它,而 TUI 可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 - **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 @@ -68,4 +68,4 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - 输入元数据仅限非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续注册表或消费方扩展。 - 通用命令输出仅实时存在,TUI 重启后不会重建。 - 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 消费它。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 运行入口不暴露命令平面;只有 TUI 消费它。 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml index ee932dd716..461e95c391 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.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-21-local-instruction-overlay.md -2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8 +2026-07-21-local-instruction-overlay.md: fb5f916d426595a80bbbaa4192e4a3975b922b8a 2026-07-21-local-instruction-overlay.zh.md: c97ed04607f497d829da0e904c248836252c73f7 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md index 3c7b2141b0..fb5f916d42 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md @@ -26,7 +26,7 @@ The base and local candidates in one directory must stay independent across base **Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read. -**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. +**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever entry point remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. **Reuse the bare directory as the scope key for base and local files.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A distinct scope key per candidate keeps them independent without widening the persisted metadata shape. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 626d98b3c8..da84a269ed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.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-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 78017b14a806f8e609a85e340094cd2a349d47e1 -2026-07-24-web-session-model-selector.zh.md: 87a342721f5e47f5bcedfafb578eb6916101d2f5 +2026-07-24-web-session-model-selector.md: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0 +2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 78017b14a8..e6a96ac62f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-session-model-selector.zh.md) ## Problem -The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. +The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front ends. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 87a342721f..5a0245359d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 +Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前端中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 ## 决策 Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会话已经使用过模型,提供方/模型/推理(reasoning)选择来自最新的 `request/header`;否则来自 `ctx.agentDefaultModel`。`session.selectModel` 会赋值会话级选择,提示词组装则将它与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的选择通过完整的 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录的 surface,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前端对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录所在的前端,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整的 `ModelSelection`、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 选择。失败时保留先前的选择和可用分组。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 684931decb..de5133b527 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: ed7e7a424d2cacadea890506fd9150ffdf7a993c -2026-08-07-default-model-follows-the-picker.zh.md: 523c6f917dedf21c726ce5631226ba545126c757 +2026-08-07-default-model-follows-the-picker.md: 2a3ada55486345c0f58f0767bed5a93ecba04b88 +2026-08-07-default-model-follows-the-picker.zh.md: 08fecc6ec9b177f6424ada3172c67018ac72baea diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index ed7e7a424d..2a3ada5548 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -6,13 +6,13 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) ## Problem -A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state. +A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent entry points cannot share it without depending on Host or duplicating state. Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept. ## Decision -`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 523c6f917d..08fecc6ec9 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的前门只有依赖 Host 或复制状态才能共享它。 +会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的入口只有依赖 Host 或复制状态才能共享它。 推理强度使持久化形态成为约定的一部分:不含强度的模型选择必须清除已存强度,否则下一个 Agent 可能会采用所选模型不接受的强度。 ## 决定 -`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定前门,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index f4e308c8f2..730f57e681 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.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-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 566eeb5b2a09a0d07d72a68e4a5f449d2822e708 -2026-08-08-dsh-run-headless-command.zh.md: 177410e783a37026940829da5f81343ddc61cb29 +2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed +2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index 566eeb5b2a..ed095f4077 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. -The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 177410e783..89d54e3557 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 -[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 `run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 005b3cad67..b44a25790d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 74d9f01f191005db6d3d283a3c56f5ee664447f8 -2026-08-08-user-explicit-skill-invocation.zh.md: 0f7c9e1261dda796d988c17ddf7199b74be4133e +2026-08-08-user-explicit-skill-invocation.md: a7c2c15703af318cb4112f2d3dfda698bc5e3bc2 +2026-08-08-user-explicit-skill-invocation.zh.md: a8d685e5eb12766bebddebb7f5993579cf08531e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 74d9f01f19..a7c2c15703 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -10,14 +10,14 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every entry point: - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. - The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 0f7c9e1261..a8d685e5eb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -10,14 +10,14 @@ Status: implemented ## 决策 -用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种运行入口一致: - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 - 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index ae738e3fad..95b38715c2 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.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/process/2026-07-05-uniform-agent-note-format.md -2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b +2026-07-05-uniform-agent-note-format.md: c05d81c700b81f0d619173a261f405b1cc039df1 2026-07-05-uniform-agent-note-format.zh.md: 3daa686b64b31ee2638b25dd4c42e5d8172f1d97 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md index 06082251c1..c05d81c700 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md @@ -23,7 +23,7 @@ The whole corpus was normalized in the same change that defined the format — t - **A bare `# ` H1** — rejected: the `Agent Note: ` prefix self-describes the genre when a file is read outside its tree, and the format gate prevents it from drifting. - **`## What we give up` as the implemented closer** (the README's own phrase for what an Agent Note records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. - **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. -- **A standalone `FORMAT.md` contract file** — rejected because one front door carrying layout, classification, and format is easier to discover and maintain than two contract files. +- **A standalone `FORMAT.md` contract file** — rejected because one entry point carrying layout, classification, and format is easier to discover and maintain than two contract files. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml index ce16a025cc..5c5386b2dd 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.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/process/2026-07-19-remove-generated-agent-note-index.md -2026-07-19-remove-generated-agent-note-index.md: ee85ec0757d5924f5784c43a50003eb96e0a9531 +2026-07-19-remove-generated-agent-note-index.md: 652ac72afe69284240667e61af1f3fbfcb182ddb 2026-07-19-remove-generated-agent-note-index.zh.md: 6e1967fbde0c6ac59bbc3a184dad68c989efadbc diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md index ee85ec0757..652ac72afe 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md @@ -12,7 +12,7 @@ The centralized chronological list adds little discovery value beyond browsing t ## Decision -The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated front door and contract, while ordinary tree navigation and repository search provide discovery. +The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated entry point and contract, while ordinary tree navigation and repository search provide discovery. `scripts/agent-note-tree.ts` owns the closed lifecycle/class sets and structural walker. `verify-agent-note-classification` validates that tree and rejects the legacy homes and a root `INDEX.md`; it does not render or freshness-check a centralized list. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..61942db772 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e +2026-07-22-product-first-root-readme.md: 00f6084da9e83135c881abfef21b0b249cd4e30a 2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..00f6084da9 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-product-first-root-readme.zh.md) ## Problem -The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. +The root README is the repository's product entry point. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. ## Decision @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer entry point have different navigation and maintenance needs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index b124dbfd9e..e305a9d10e 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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/process/2026-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: f429f3d41c1f152e83faeb12c379d221627e767f -2026-08-03-package-anchored-subsystem-pages.zh.md: 3a56fa39357591197606a28b49cf0eac8f58963e +2026-08-03-package-anchored-subsystem-pages.md: 2a47f35e9d3755286bed7d42f59fd21e30f9148d +2026-08-03-package-anchored-subsystem-pages.zh.md: 53216a430b979f0612f256aaec9774b88fe14bbd diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index f429f3d41c..2a47f35e9d 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,7 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages/<group>/README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 3a56fa3935..53216a430b 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,7 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给真正服务本地或 vendored 的形状。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.md);core.md 概括并链接)。 -每个 `packages/<group>/README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 +每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index d09614a7fa..66e573773b 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.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/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 29da3025251f9826c5780493bb2725b114a40601 +2026-07-23-acp-automation-only-protocol.md: 56c433acf59d3a0c4b5c8b6605e422d3c4efede2 2026-07-23-acp-automation-only-protocol.zh.md: bf326ce9f6fea8c55b842bc61e3d8521032ea4d1 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 29da302525..56c433acf5 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -46,7 +46,7 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat ## Consequences -ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor front door. +ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index 73c3fb6e55..bcf543a344 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.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/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: f048a04db02b582d038ebf549999c0fda50e30d3 +2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd 2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index f048a04db0..19cc7d1a89 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-remove-tui-package.zh.md) Removing the implicit `dsh` terminal application left `@deepseek-ai/dsh-tui` without a shipped composition. The package still carried a terminal renderer, interactive command and question adapters, extension overlays, snapshot fixtures, a patched `pi-tui` dependency, and SDK scaffolding that advertised TUI as a supported application interface. Keeping that surface required maintaining a product-sized frontend whose only remaining consumer was the project generator itself. -The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI front doors, while the SDK continued to offer a terminal choice that no example or product command exercised. +The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI entry points, while the SDK continued to offer a terminal choice that no example or product command exercised. ## Decision @@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa ## Consequences -DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web front doors. +DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 9a88d14722..217265168d 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.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/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: c1153f5e9fcc89585e926f8f088829c849d1f6c1 +2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index c1153f5e9f..403e01f94c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two front doors also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 3e3918c2ab..e4a719379e 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -243,7 +243,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted front door can publish readiness before sibling rows + // settles: an inserted entry point can publish readiness before sibling rows // finish mounting. process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) process.on('SIGINT', () => { interrupt(130) }) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 911b25d0cc..22c74a441e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 90d64fb0ac62020e13a7ce995c8e3273a5a9f906 +architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd diff --git a/docs/architecture.md b/docs/architecture.md index 90d64fb0ac..ebf05397cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | -| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent front doors | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent entry points | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -164,7 +164,7 @@ Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC entry points ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Agent Presets diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 05186f3e56..085d993fef 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 05788eb8044f91e31c82dc4b78af0421e2b11030 -capability-seams.zh.md: ae182cfeeb1d7461122d791eedb818160a772e9b +capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11 +capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 05788eb804..345e17c8c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -399,7 +399,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/self-modification/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | @@ -407,7 +407,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index ae182cfeeb..472aaabf4c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -401,7 +401,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm)、[`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop)、[`tools`](../packages/core/tools)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop)、[`tool-ask-user`](../packages/interaction/tool-ask-user)、[`tool-bash`](../packages/bash/tool-bash)、[`tool-cordis`](../packages/self-modification/tool-cordis)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-skill`](../packages/skill/tool-skill)、[`tool-subagent`](../packages/subagent/tool-subagent)、[`tool-todo`](../packages/todo/tool-todo)、[`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 入口提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | @@ -409,7 +409,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge)、[`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop)、[`acp`](../packages/acp/acp)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接前门与 Host 支撑的 Agent 前门共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b)、[`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index fd29baab75..8fb1893fca 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.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/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 38483b5c4993a44562970782dca5f676e4cb84f6 -07-into-the-harness.zh.md: 59ce716bdace894682bc1c8e6e00cf174008c26c +07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 +07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 38483b5c49..69133786f5 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -95,7 +95,7 @@ The logger fired first: `tools/result` is emitted as part of result materializat ## From here to a full agent -A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, an entry point. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. Where to go next: diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 59ce716bda..bc9c61da98 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -95,7 +95,7 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 ## 从这里走向完整 agent(智能体) -真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和运行入口。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 后续可以阅读: diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e540ed7d90..0b2b87a954 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/subsystems/core.md -core.md: 9a1fa827a7e0168e662bc4595a3c0fc486be8d49 -core.zh.md: e51f8b61fb6ed0c7a6e2de377f8f93877f972831 +core.md: af27484160769156836f377e5b3aba2521280005 +core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 9a1fa827a7..af27484160 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -330,7 +330,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index e51f8b61fb..12935f4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -338,7 +338,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index f4a719fdb2..c95a0db6c0 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: 29575fcc860721d35588d82a15d9b97f1b7dea0e -providers.zh.md: 4b5fd32224fbfe5b685f886375b590fa3704505d +providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 +providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 29575fcc86..4667e54161 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct front doors and Host-backed front doors read that same service. +After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service. If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 4b5fd32224..15e0ccc826 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接前门与 Host 支撑的前门都读取同一服务。 +会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。 如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 5e6a21e1ec..49614b8c2d 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_acp_acp_agent plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] + plugin_acp_acp_agent --> entrypoint_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 07a1dc582a..90dd1c4f2a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/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 examples/headless-agent/README.md -README.md: 6e80e56dec70c2be341ae5dfbad70e13a5109715 +README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 6e80e56dec..f12a56920c 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product front door. +This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product entry point. ## Run it diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..977afa8863 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -65,7 +65,7 @@ - id: agent name: '@deepseek-ai/dsh-agent' - # The transport-independent default for Agents created by front doors. + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..e099db50fc 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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/client/ui-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f +README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..d8e88cb7b0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. +A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..073a41cac9 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..2a2d67744e 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -8,7 +8,7 @@ * determinism * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a * leading `/name` naming a user-invocable skill and injects the rendered - * body for every front end, including `disable-model-invocation` skills the + * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's * root-context connection captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from @@ -167,7 +167,7 @@ export function apply(ctx: ClientContext): void { // lands plain text and the prompt ships the same // literal. Determinism lives host-side — the host's // pre-step boundary (dsh-tool-skill) recognizes the leading /name and - // injects the rendered body for every front end. A name shared with a + // injects the rendered body for every entry point. A name shared with a // host command still resolves to the command: adjudication claims the // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index 4d9ab302d8..44e417c057 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/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/README.md -README.md: 8349371ab565f2e9e735cd959026936c7ec44081 +README.md: 504686f8563f073fc8261c88275a5cdd172dd060 README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92 diff --git a/packages/core/README.md b/packages/core/README.md index 8349371ab5..504686f856 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy | [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | | [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | -| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` | +| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own. +`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own. Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 92c6095788..7835a159bc 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/agent-default-model/README.md -README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb +README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 02bcc9be3a..98bc7d082e 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. @@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an ## Model Experience -Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request. +Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request. #### KV Cache effect @@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e ## Known Limitations and Deferred Work -- The service owns one process-wide default; per-session selection remains the front door's responsibility. +- The service owns one process-wide default; per-session selection remains the entry point's responsibility. - Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent. diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index d012fbea93..0035b0b617 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-default-model", - "description": "Default model selection shared by Agent front doors", + "description": "Default model selection shared by Agent entry points", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 36b3b9ba44..4d09b86eb3 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service { /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> { diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index 4d36ca34fb..a49e2f5979 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -1,5 +1,5 @@ /** - * Agent-scoped model selection shared by interactive front doors. + * Agent-scoped model selection shared by runtime entry points. * @module @deepseek-ai/dsh-agent/model-selection */ @@ -33,7 +33,7 @@ export interface ModelSelectionRef { * the selected model's provider/default behavior. * * @param agentCtx - The selected Agent's scoped context. - * @param selection - Mutable selection owned by the calling front door. + * @param selection - Mutable selection owned by the calling entry point. * @returns Disposer for both scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index e77a9283b2..2270947eea 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0 -README.zh.md: 24f64096dda0ccdac51afb90754ae25950750b89 +README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 +README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 diff --git a/packages/examples/README.md b/packages/examples/README.md index 2d672dcc30..d8369b1e26 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. +Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and an entry point by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. | Package | npm name | Role | |---|---|---| @@ -10,8 +10,8 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. -These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions. +These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 24f64096dd..acb402e925 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和运行入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| @@ -12,6 +12,6 @@ `agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 -这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包选择具体组合。 +这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index ff17cf5bb7..af16e2eacd 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/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/examples/acp-demo/README.md -README.md: 395ab230146568989c4e6d1361218efb72d857e7 -README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6 +README.md: edc45c9857a631cef72eb41b1a98c390f112291e +README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 395ab23014..edc45c9857 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,4 +55,4 @@ Append-only per session; the app adds no request-prefix content itself. - **JSONL persistence is fixed** — a different backend requires another composition. - **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes. -- **Fresh automation sessions only** — resume and human interaction belong to other front doors. +- **Fresh automation sessions only** — resume and human interaction belong to other entry points. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 667fc1a794..c2946aa3d1 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -55,4 +55,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 - **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 - **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。 -- **只支持新建自动化会话**:恢复和人工交互属于其他前端入口。 +- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。 diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 68a2909836..8a900dd3cd 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -71,7 +71,7 @@ export interface Config { goals?: agentCore.GoalConfig | false } -// Each front door owns a complete, directly readable config schema; extracting +// Each entry point owns a complete, directly readable config schema; extracting // the common fields would make two small app contracts depend on a new facade. /* jscpd:ignore-start */ export const Config: z<Config> = z.object({ @@ -114,7 +114,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> { const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) await spine yield spine.dispose - // Same rationale as the Config schema above: each front door forwards its own + // Same rationale as the Config schema above: each entry point forwards its own // persistence passthroughs rather than sharing a facade with stdio-demo. /* jscpd:ignore-start */ const persistence = ctx.plugin(SessionPersistenceJsonl, { diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a698160634..44de45d88f 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: cfec2c46ada8ed97aef44fb1d4145ddecdeecab1 -README.zh.md: d482ea9ca7034874383472050a8c837bea5bfaad +README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 +README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cfec2c46ad..cf0dc2ecd6 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. +The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only an entry point and the swappable backends. Read this package for the whole plugin tree and its composition order. @@ -41,15 +41,15 @@ Read this package for the whole plugin tree and its composition order. ## What it deliberately leaves OUTSIDE the bundle -The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: +The spine is everything COMMON to every entry point. The swappable and entry-point-coupled pieces stay out, picked by whatever loads the bundle: - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. +- **entry point + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. -This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. +This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the entry point. ## Config @@ -65,9 +65,9 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ ## Why a code bundle, not a shared YAML include -A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +A YAML include can deduplicate config but cannot own a bin or provide entry-point defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, entry points derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index d482ea9ca7..e5a8672d49 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -41,15 +41,15 @@ ## 有意留在组合包外的组件 -主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: +主干包含每个入口都共有的全部组件。可替换组件和与入口耦合的组件留在外部,由加载组合包的一方选择: - **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 +- **入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 -这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 +这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有入口。 ## 配置 @@ -65,9 +65,9 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 ## 模型体验 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 30c51fc941..7d9e99a908 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -165,7 +165,7 @@ export const Config = z.intersect([ ]) as unknown as z<Config> /** - * Copy the bundle-owned fields from an app config without leaking front-door settings. + * Copy the bundle-owned fields from an app config without leaking entry-point settings. * @param config - App config containing the shared spine fields. * @returns The fields accepted by this bundle, preserving optional absence. */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8ffd971829..8de047a79a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -236,7 +236,7 @@ describe('dsh-agent-spine-demo bundle', () => { } }) - it('loads and configures bounded request recovery for every bundled front door', async () => { + it('loads and configures bounded request recovery for every bundled entry point', async () => { const adapter = new TransientOnceAdapter() const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) @@ -704,9 +704,9 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('picks shared spine config without leaking front-door fields', () => { + it('picks shared spine config without leaking entry-point fields', () => { const appConfig = { - model: 'front-door-only', + model: 'entrypoint-only', includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index bc4a93d760..d919320643 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: e2eb6d4cf2b40e83efad1fa158edd72578658f56 +README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index e2eb6d4cf2..d7849e25fc 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -56,4 +56,4 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. -- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 30f7a65c51..cb86d86c92 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] // Run the hook in the agent's session workspace (the `session/new` cwd on the session - // header), not the executor or front-door process's launch dir. + // header), not the executor or entry-point process's launch dir. const workdir = opts.agent?.session.header.cwd // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session // workspace (the same dir the hook runs in). diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..cba01a89bf 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f +README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..c29f30b85c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,13 +50,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..92b866bafd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index bd060e7d19..3e5157ca90 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,7 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. * * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct front doors. Switching models persists through that + * shared with direct entry points. Switching models persists through that * service; sessions that have already logged a selection remain unchanged. */ diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 7d4217d3d4..2a9323474e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 6c8ba23b76e83665d4f8dcb5ecb41689347f6423 +README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6c8ba23b76..c404cfa730 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. -The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. +The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection diff --git a/packages/scaffold/helper/src/features/builtin/app.ts b/packages/scaffold/helper/src/features/builtin/app.ts index 106cf07097..1695accf56 100644 --- a/packages/scaffold/helper/src/features/builtin/app.ts +++ b/packages/scaffold/helper/src/features/builtin/app.ts @@ -50,7 +50,7 @@ class AppOption extends FeatureOption { this.label = label } - /** Identify options by their unique front door, not the shared interaction service. */ + /** Identify external options by their run interface, not the shared interaction service. */ override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] @@ -58,7 +58,7 @@ class AppOption extends FeatureOption { } } - /** Embed is identified by the configured loop with no external front door. */ + /** Embed is identified by the configured loop and absence of an external entry point. */ override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') @@ -93,7 +93,7 @@ export class AppFeature extends ExclusiveOptionFeature { new AppOption('embed', 'Embedded context'), ] - /** Default to the profile's already selected front door. */ + /** Default to the profile's already selected run interface. */ override defaultOptions(profile: ProjectProfile): readonly string[] { return [profile.runInterface] } diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 5eb3d2bc5a..93d51c5ffe 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -112,7 +112,7 @@ export abstract class Feature { readonly requires: readonly FeatureId[] = [] /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] - /** Front doors under which this feature is meaningful. */ + /** Run interfaces under which this feature is meaningful. */ readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed'] /** @@ -141,7 +141,7 @@ export abstract class Feature { } /** - * Whether the feature may be selected for this project front door. + * Whether the feature may be selected for this project run interface. * @param profile - project context to check. * @returns whether the feature applies. */ diff --git a/packages/scaffold/helper/src/project/types.ts b/packages/scaffold/helper/src/project/types.ts index 0c66bb5ac2..72f4259e88 100644 --- a/packages/scaffold/helper/src/project/types.ts +++ b/packages/scaffold/helper/src/project/types.ts @@ -8,7 +8,7 @@ import type { PackageManager } from '../package-managers/package-manager.ts' import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' -/** Runtime front door selected for a generated project. */ +/** Run interface selected for a generated project. */ export type RunInterface = 'acp' | 'embed' /** Values shared by the required provider and app features. */ diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index d0f275ca4c..41acf530ba 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -209,7 +209,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) - it('round-trips embed app projects without a front-door Cordis config entry', async () => { + it('round-trips embed app projects without an ACP Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) const creation = request([], [], 'embed') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 0d984e79e1..86def75601 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise<void>', - jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */', + jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by an entry point.\n * @returns fulfillment after the optional settings write settles.\n */', }, ], }, diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 3c99c7a770..6b3e20a82c 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for a terminal front door, the host terminal. + * must land somewhere the user can see — for a terminal entry point, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 59a8e0200f..26e9233f82 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -258,7 +258,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Human question/answer seam', mode: 'seam', consumers: ['tool-ask-user'], - note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { key: 'planMode', @@ -320,7 +320,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Default Agent model selection', mode: 'core', consumers: ['headless', 'host-apiproxy'], - note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.', + note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { key: 'agentLoop', @@ -682,7 +682,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-acp-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) + lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) } lines.push( ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`, From 75100ad2e5e0d081d3f81a22fdd6bd11bf464631 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:22:55 +0800 Subject: [PATCH 60/62] fix(agent-presets): repair Windows preset CI --- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/README.md | 2 ++ packages/preset/agent-presets/README.zh.md | 2 ++ packages/preset/agent-presets/src/mount.ts | 13 +++++--- .../agent-presets/tests/authoring.spec.ts | 17 ++++++----- .../agent-presets/tests/discovery.spec.ts | 30 +++++++++++++++++-- .../preset/agent-presets/tests/mount.spec.ts | 19 +++++++++++- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index cb80d89ad8..65af853308 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 -README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 +README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d +README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index ed640cf053..b6d469b26a 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. +An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts. + ### Display metadata A preset may publish display text in an optional `preset.yml` beside its composition: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4e6fc0a4cf..60c7bc695c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 +**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。 + ### 展示用元信息 preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index fac3319fa1..eb890255ca 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -14,6 +14,7 @@ * @module @deepseek-ai/dsh-agent-presets/mount */ +import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' @@ -69,21 +70,25 @@ class PresetTree extends Include { * where Node's upward `node_modules` walk never reaches the harness's own * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The * mount records the host composition's base instead, which is inside the - * installed harness, and bare names resolve from there. + * installed harness, and bare names resolve from there. An absolute + * filesystem path names neither base and becomes a file URL before Node's + * ESM loader receives it, which is required for drive-letter paths on + * Windows. * @param name - the module specifier from the row. * @param getOuterStack - the loader's stack composer for import diagnostics. * @returns the imported module, or the `cordis:` builtin. */ override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name const base = harnessBase.get(this.config) /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ - if (base === undefined) return super.import(name, getOuterStack) + if (base === undefined) return super.import(specifier, getOuterStack) if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) const internal = this.ctx.loader.internal /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a hypothetical embedder from losing the row's name in a resolution error. */ - if (internal === undefined) return super.import(name, getOuterStack) - return internal.import(name, base, {}) + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, base, {}) } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b71776c264..b6f4ef388a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -65,20 +65,23 @@ describe('copying a preset', () => { expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') }) - it('copies the whole directory, execute bits kept and group/other stripped', async () => { + it('copies the whole directory and tightens POSIX modes', async () => { await seedPreset(userRoot, 'source', { extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, }) - await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + if (process.platform !== 'win32') { + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + } await ctx.agentPresets.copy('source', 'mine') expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') - // A preset may ship runnable helpers; the copy keeps them runnable for the - // owner while withdrawing the world-readability of the install. - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) - expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + // Windows mode bits are synthetic and cannot represent the inherited DACL. + if (process.platform !== 'win32') { + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + } }) it('keeps the source description but never its name or order', async () => { diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 55845c2251..165a4cd17f 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,14 +1,37 @@ -import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs/promises')>() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest) + }) as typeof actual.readFile, + } +}) + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +beforeEach(() => { + fsHarness.nextReadError = undefined +}) + describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) @@ -177,10 +200,11 @@ describe('composition health', () => { await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') - await chmod(path, 0o000) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) const [preset] = await scanRoot({ path: root, trust: 'user' }) + expect(fsHarness.nextReadError).toBeUndefined() expect(preset?.broken).toMatch(/cannot be read/) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b4988331cc..c84dae525b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -84,6 +84,23 @@ beforeEach(async () => { }) describe('composing an agent from a preset', () => { + it('hands an absolute plugin path to Node as a file URL', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + const presetDir = join(root, 'absolute') + const plugin = join(FIXTURES, 'plugins', 'contribute.js') + await mkdir(presetDir) + await writeFile( + join(presetDir, COMPOSITION_FILE), + `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, + ) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const imported = vi.spyOn(scoped.loader.internal!, 'import') + + await agentOn(scoped, 'sess-absolute-plugin') + + expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {}) + }) + it('gives each session only its own preset\'s tools', async () => { const alpha = await agentOn(ctx, 'sess-alpha', 'standard') const beta = await agentOn(ctx, 'sess-beta', 'minimal') From 52fafa012e8616b5587863b0e2fe05221255a982 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:30:02 +0800 Subject: [PATCH 61/62] test(agent-presets): stabilize generation race coverage --- .../preset/agent-presets/tests/mount.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index c84dae525b..77c549a689 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -542,6 +542,34 @@ describe('editing a composition file', () => { expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) }) + it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => { + const { scoped, path } = await editable('guarded-refresh') + const preset = await scoped.agentPresets.resolve('guarded-refresh') + await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh') + const service = scoped.agentPresets as unknown as { + standing: Map<string, Promise<{ + key: unknown + scope: unknown + stamp: { mtimeMs: number; size: number } + }>> + ensureStanding(current: typeof preset): Promise<unknown> + } + const stalePromise = service.standing.get(preset.id)! + const stale = await stalePromise + await writeFile(path, rowFor('afterwards')) + const { mtimeMs, size } = await stat(path) + const newer = { ...stale, stamp: { mtimeMs, size } } + const newerPromise = Promise.resolve(newer) + + // `await pending` yields before the guarded delete, letting the winning + // refresher replace the pointer deterministically instead of by timing. + const refresh = service.ensureStanding(preset) + service.standing.set(preset.id, newerPromise) + + expect(await refresh).toBe(newer) + expect(service.standing.get(preset.id)).toBe(newerPromise) + }) + it('hands a host reader the standing key without starting an agent', async () => { const { scoped } = await editable('cold-read') From 6a830f1779657b901e017984273a354a2ecd4aeb Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:44:37 +0800 Subject: [PATCH 62/62] test(web): stabilize preset-aware steering snapshots --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 ++ apps/web/tests/snapshots/steer-all/settled.expected.md | 2 ++ apps/web/tests/steering.e2e.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..222097e0a3 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..d9f1893344 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 8a09582b56..a527081fbe 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => {