From f5dea38671beb4054036ab73ed03963d108c0153 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Mon, 26 Oct 2026 20:15:50 +0800 Subject: [PATCH 01/16] feat(sandbox): add session-scoped writable directory effects --- ...026-10-08-persistent-ptc-sandbox.i18n.yaml | 4 +- .../2026-10-08-persistent-ptc-sandbox.md | 2 +- .../2026-10-08-persistent-ptc-sandbox.zh.md | 2 +- ...ssion-writable-directory-effects.i18n.yaml | 19 +++++ ...0-26-session-writable-directory-effects.md | 27 +++++++ ...6-session-writable-directory-effects.zh.md | 27 +++++++ docs/subsystems/sandbox.md | 16 +++- docs/subsystems/sandbox.zh.md | 16 +++- .../extensions/tool-cordis/src/api-catalog.ts | 9 ++- packages/fs/fs-sandbox/package.json | 1 + .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 28 ++++++- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/execution.ts | 17 +--- .../tests/persistent-sandbox.spec.ts | 41 ++++++---- .../ptc-runtime-node/tests/persistent.spec.ts | 4 +- .../sandbox/sandbox-policy/README.i18n.yaml | 3 + packages/sandbox/sandbox-policy/README.md | 6 ++ packages/sandbox/sandbox-policy/README.zh.md | 6 ++ packages/sandbox/sandbox-policy/src/index.ts | 41 +++++++++- .../sandbox-policy/tests/policy.spec.ts | 78 +++++++++++++++++++ .../tests/fixtures/session-write-grant.ts | 23 ++++++ pnpm-lock.yaml | 3 + .../session-sandbox-root/cordis.snapshot.yml | 3 +- .../session/session-sandbox-root/cordis.yml | 5 +- .../session-sandbox-root/session.v4.jsonl | 5 +- 27 files changed, 343 insertions(+), 51 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md create mode 100644 packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml index 262e03bb22..ff9a3301b2 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml @@ -15,5 +15,5 @@ en: 10bebc7f982cf7f4 zh: 9247a2ba57921a11 /agent-note-retained-ptc-processes-follow-file-policy/consequences: - en: b3ddf273158d3754 - zh: 916356f4074b3622 + en: 1f94ca28e0cb7564 + zh: d8a918c0c5dfc6d8 diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md index 2c4235fcc3..21c3d59188 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md @@ -28,4 +28,4 @@ An approved one-time escalation omits the state owner and receives a fresh tempo ## Consequences -Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. +Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before changing its mode, workspace root or Session identity; the provider rejects inconsistent reuse. [Directory-effect lifetimes](2026-10-26-session-writable-directory-effects.md) permit extra-directory changes while retained processes keep their launch grants. The sandbox inherits the existing backends’ file-effect coverage and platform limitations. diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md index d841702941..6403ed5ef1 100644 --- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md @@ -28,4 +28,4 @@ Status: implemented ## 影响 -普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。 +普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。运行时消费者在更改模式、工作区根目录或 Session 身份前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。[目录 effect 生命周期](2026-10-26-session-writable-directory-effects.zh.md)允许额外目录发生变化,同时保留进程继续使用启动时的授权。沙箱继承现有后端的文件约束范围与平台限制。 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml new file mode 100644 index 0000000000..77a0e4417c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-26-session-writable-directory-effects.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +/agent-note-session-writable-directory-effects: + en: b5a98da65d44e212 + zh: ba6842a9b4a890f7 +/agent-note-session-writable-directory-effects/problem: + en: 2a32db1f27371403 + zh: 89cf04705ef66769 +/agent-note-session-writable-directory-effects/decision: + en: a6618e4ee649c198 + zh: 5756dad5dd01dede +/agent-note-session-writable-directory-effects/alternatives-considered: + en: dad77744dea17bde + zh: bb3838f9f9099944 +/agent-note-session-writable-directory-effects/consequences: + en: 7edcc1022b9f3ee5 + zh: 61163e25d5a90f16 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md new file mode 100644 index 0000000000..1292e43178 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -0,0 +1,27 @@ +# Agent Note: Session writable-directory effects + +Status: implemented + +English | [中文](2026-10-26-session-writable-directory-effects.zh.md) + +## Problem + +Memory plugins need agent writes outside the workspace without granting every Session the same directory. Deployment-wide roots cannot express a plugin enabled for only one Session or a grant that ends when that plugin unloads. + +## Decision + +The sandbox-policy service owns effect-scoped directory registrations keyed by live Session identity. Configured grants remain shared; runtime grants apply only to their named Session. Each resolution captures the active union, and existing request-context logging records the directories advertised to the model. Resume and fork require new registrations rather than restoring authority from historical text. + +Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged. + +## Alternatives considered + +**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service. + +**Immediate process revocation.** Existing sandboxes apply grants at launch. Stopping and recreating processes would discard terminal or PTC state and require cleanup coordination beyond the accepted future-operation requirement. + +**Persist runtime grants as Session permissions.** Replaying such grants could authorize directories after the plugin that supplied them has been removed. The active plugin composition owns these grants. + +## Consequences + +Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocess confinement retains its existing unsupported-extra-roots error. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md new file mode 100644 index 0000000000..8c934b20ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 会话可写目录 effect + +Status: implemented + +[English](2026-10-26-session-writable-directory-effects.md) | 中文 + +## 问题 + +记忆插件需要允许 agent 在工作区之外写入,同时不向所有 Session 授予相同目录。部署级根目录无法表达只为一个 Session 启用的插件,或随插件卸载而结束的授权。 + +## 决策 + +sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周期管理的目录注册。配置授权仍然共享;运行时授权只适用于指定 Session。每次解析捕获当前授权的并集,现有请求上下文日志记录向模型展示的目录。恢复与派生需要重新注册,而非从历史文本恢复权限。 + +注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。 + +## 考虑过的替代方案 + +**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。 + +**立即撤销进程权限。** 现有沙箱在启动时应用授权。停止并重新创建进程会丢失终端或 PTC 状态,并需要超出已接受的后续操作需求的清理协调。 + +**将运行时授权持久化为 Session 权限。** 回放此类授权可能在提供授权的插件已被移除后继续授权目录。当前插件组合拥有这些授权。 + +## 影响 + +同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程隔离保留现有的不支持额外根目录错误。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 1b8c843049..2fe2c319c1 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -208,12 +208,26 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * mode outranks the session's last `sandbox/mode` event, which outranks the * deployment default. A session cwd is its workspace-write boundary; the * configured root is the fallback for agentless calls and sessions without a - * cwd. + * cwd. Configured extra roots and this Session's active registrations are + * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +/** + * Permit writes under one directory in this Session's future workspace-write + * operations. The calling context owns the effect; disposal removes only this + * registration. Other grants to the directory remain effective. Existing + * operations and persistent processes retain their resolved permissions. + * Grants are runtime-only and must be registered again after resume or fork. + * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. + * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @returns the exact Cordis effect disposer for this registration. + * @throws when the directory is relative or contains a NUL byte. + */ +registerWritableRoot(session: Session, directory: string): () => void + /** * Read the session override without applying the deployment default. * @param session - session whose log supplies the override. diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 8141d365c0..7e54a4a5fd 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -208,12 +208,26 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * mode outranks the session's last `sandbox/mode` event, which outranks the * deployment default. A session cwd is its workspace-write boundary; the * configured root is the fallback for agentless calls and sessions without a - * cwd. + * cwd. Configured extra roots and this Session's active registrations are + * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +/** + * Permit writes under one directory in this Session's future workspace-write + * operations. The calling context owns the effect; disposal removes only this + * registration. Other grants to the directory remain effective. Existing + * operations and persistent processes retain their resolved permissions. + * Grants are runtime-only and must be registered again after resume or fork. + * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. + * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @returns the exact Cordis effect disposer for this registration. + * @throws when the directory is relative or contains a NUL byte. + */ +registerWritableRoot(session: Session, directory: string): () => void + /** * Read the session override without applying the deployment default. * @param session - session whose log supplies the override. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6d2208cf96..a11efdbd50 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2579,10 +2579,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', - description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd.', + description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd. Configured extra roots and this Session\'s active registrations are snapshotted together; existing operations and processes keep their policy.', parameters: [{ name: 'request', description: 'optional session and approved mode override.' }], returns: 'the fully resolved per-call mode and absolute workspace root.', }, + { + signature: 'registerWritableRoot(session: Session, directory: string): () => void', + description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.', + parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.' }], + returns: 'the exact Cordis effect disposer for this registration.', + throws: ['when the directory is relative or contains a NUL byte.'], + }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', description: 'Read the session override without applying the deployment default.', diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index ee56e522ea..86820aa118 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 382668591d..e0a00e2adb 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -16,7 +16,8 @@ import { join, parse } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' -import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' @@ -91,6 +92,31 @@ describe('read-only', () => { describe('workspace-write containment', () => { beforeEach(() => boot('workspace-write')) + it('applies session grants to later writes and removes them when their plugin unloads', async ({ onTestFinished }) => { + onTestFinished(() => ctx.fiber.dispose()) + const first = Session.create(SessionId('memory-first')) + const second = Session.create(SessionId('memory-second')) + const path = join(outside, 'memory.txt') + const write = async (session: Session, content: string) => fs.writeText( + await target(path), content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }), + ) + await expect(write(first, 'denied')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + const plugin = await ctx.plugin({ + inject: ['sandboxPolicy'], + apply(scope: Context) { scope.sandboxPolicy.registerWritableRoot(first, outside) }, + }) + await write(first, 'memory') + expect(await readFile(path, 'utf8')).toBe('memory') + await expect(write(second, 'other session')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + setSandboxMode(first, 'read-only') + await expect(write(first, 'read-only')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + setSandboxMode(first, 'workspace-write') + await write(first, 'updated') + await plugin.dispose() + await expect(write(first, 'revoked')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(await readFile(path, 'utf8')).toBe('updated') + }) + it('rejects an extra directory replaced by a symlink to an ungranted sibling', async () => { const extra = join(workspace, 'extra') await mkdir(extra) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index f906f1a694..1ae88f5606 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2a9fa3292bf07355 zh: db0b5684820915eb /deepseek-ai-dsh-ptc-runtime-node/use-this-package/in-memory-modes: - en: 0204fbdcb55c9c0d - zh: 8e6b6f3974993ca0 + en: b1d9e8ce46f265ae + zh: d2294ca991f349df /deepseek-ai-dsh-ptc-runtime-node/use-this-package/deadlines-and-cancellation: en: d383ec7aeab8afec zh: 7c9c49e205fbbcd8 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 1ea61221bd..a2201ea7b2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -95,7 +95,7 @@ Save the patch and pass its path to `dsh headless --patch ` in a compositi In REPL mode, each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Assignments to undeclared names create retained globals under Node REPL’s non-strict evaluation. `_` and `_error` are ordinary retained globals, without interactive REPL result setters. Node REPL declaration rules apply, including errors on redeclaring existing `let`, `const`, or `class` names; assign to a mutable binding or choose a new name. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation. -Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Exceptions in the active evaluation settle once and preserve earlier mutations. In REPL mode, exceptions from callbacks belonging to a completed evaluation are not reported in a later result. In Script mode, uncaught callback exceptions can terminate the retained process; await asynchronous work before completing an evaluation. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Each evaluation resets the process working directory to its resolved cwd; program calls to `process.chdir()` do not persist across evaluations. Direct Node access follows the resolved file policy. A retained owner cannot change mode, workspace root, additional writable roots, or Session identity until its environment is released. +Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Exceptions in the active evaluation settle once and preserve earlier mutations. In REPL mode, exceptions from callbacks belonging to a completed evaluation are not reported in a later result. In Script mode, uncaught callback exceptions can terminate the retained process; await asynchronous work before completing an evaluation. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Each evaluation resets the process working directory to its resolved cwd; program calls to `process.chdir()` do not persist across evaluations. Direct Node access follows the process launch policy. A retained owner cannot change mode, workspace root, or Session identity until its environment is released. Additional writable directories are fixed when the process starts: later grants and revocations apply only after release and recreation. Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Their failures leave other owners' retained variables intact and do not report clearing them. Permission setters record the new mode synchronously. The runtime immediately invalidates affected processes and begins cancellation when the Session mode changes, including idle environments. The next program waits for their cleanup before starting; cleanup failure blocks that program and remains retryable. Permission-setting success does not mean old processes have finished stopping. A one-time approved escalation uses a separate environment and awaits its cleanup before returning; it cannot read the standing environment's variables or retain new declarations. Concurrent runs for one owner reject until execution and required cleanup complete; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure. Raw stdout/stderr have no evaluation identity: bytes received during an evaluation share its output budget, even when written by an earlier child process; idle stdout is discarded and idle stderr retains only the bounded diagnostic tail. Await child-process completion before ending an evaluation. Process-exit diagnostics include the exit code and retained stderr tail; idle failures are written to the host logger. If process observation fails during cleanup, releasing the owner retries termination; new runs remain blocked until cleanup succeeds. Pipes that fail to drain after confirmed process exit produce a diagnostic but do not prevent releasing the environment. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index ee8f2e01e6..4a0cbd4bda 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -95,7 +95,7 @@ profile 编辑器可从 `@deepseek-ai/dsh-ptc-runtime-node/configuration` 导入 在 REPL 模式下,每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。Node REPL 使用非严格模式,给未声明的名称赋值会创建保留的全局变量。`_` 和 `_error` 是普通的保留全局变量,不使用交互式 REPL 的结果设置器。声明遵循 Node REPL 规则,包括重复声明已有 `let`、`const` 或 `class` 名称时报错;应给可变绑定赋值或选择新名称。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。 -内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。当前执行中的异常只结算一次,并保留此前发生的修改。在 REPL 模式下,已完成执行的回调抛出的异常不会在后续结果中报告。在 Script 模式下,未捕获的回调异常可能终止保留进程;结束执行前应等待异步工作完成。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。每次执行都会将进程工作目录重置为本次解析的 cwd;程序中的 `process.chdir()` 不跨执行保留。直接 Node 访问遵循已解析的文件策略。保留状态的所有者必须先释放环境,才能更改权限模式、工作区根目录、额外可写根目录或 Session 身份。 +内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。当前执行中的异常只结算一次,并保留此前发生的修改。在 REPL 模式下,已完成执行的回调抛出的异常不会在后续结果中报告。在 Script 模式下,未捕获的回调异常可能终止保留进程;结束执行前应等待异步工作完成。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。每次执行都会将进程工作目录重置为本次解析的 cwd;程序中的 `process.chdir()` 不跨执行保留。直接 Node 访问遵循进程启动时的文件策略。保留状态的所有者必须先释放环境,才能更改权限模式、工作区根目录或 Session 身份。额外可写目录在进程启动时固定:之后的授权和撤销仅在释放并重新创建环境后生效。 服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。此类执行失败不影响其他所有者保留的变量,也不报告清空了这些变量。权限设置同步记录新模式。Session 模式变化时,运行时立即禁止复用受影响的进程并开始取消,包括空闲环境。下一次程序执行前等待这些进程清理;清理失败会阻止本次程序启动,并允许重试。权限设置成功时,旧进程可能仍在停止过程中。单次获准的提权使用独立环境,返回前等待清理;该次执行无法访问原环境的变量,也不会保留新声明。执行及必要清理完成之前,同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。原始 stdout/stderr 不携带执行标识:执行期间收到的字节计入本次输出预算,包括较早启动的子进程此时写出的内容;空闲期间丢弃 stdout,只保留限额内的 stderr 诊断尾部。结束本次执行前,应等待子进程完成。进程退出诊断包含退出码和保留的 stderr 尾部;空闲期间的失败写入宿主日志。清理期间无法观测进程范围时,释放所有者会重试终止;清理成功前拒绝新执行。确认进程退出后仍未排空的管道会产生诊断,但不阻止释放环境。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts index 4d9422ab5e..5028a9f178 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts @@ -92,10 +92,10 @@ class NodeExecution { worker?.assertIdle() if (worker && !worker.closed && (worker.policy.mode !== spec.sandboxPolicy.mode || worker.policy.workspaceRoot !== spec.sandboxPolicy.workspaceRoot - || worker.policy.sessionId !== spec.sandboxPolicy.sessionId - || !sameAdditionalRoots(worker.policy.additionalWritableRoots, spec.sandboxPolicy.additionalWritableRoots))) { + || worker.policy.sessionId !== spec.sandboxPolicy.sessionId)) { throw new Error('persistent PTC: release the retained environment before changing its sandbox policy') } + // Retained processes keep their launch-time directory grants until release. if (!worker || worker.closed) { if (worker) this.workers.delete(worker) // Direct callers can own mutable policy arrays; the retained process owns its grant snapshot. @@ -127,19 +127,6 @@ class NodeExecution { } } -/** - * Compare requested extra grants without resolving paths in another execution world. - * @param left - one policy's additional roots. - * @param right - the other policy's additional roots. - * @returns whether both contain the same paths, ignoring order and duplicates. - */ -function sameAdditionalRoots(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean { - const effective = (roots: readonly string[] | undefined): string[] => [...new Set(roots ?? [])].sort() - const a = effective(left) - const b = effective(right) - return a.length === b.length && a.every((root, index) => root === b[index]) -} - const STRIP_PREFIX = 'async function __dsh_program__() {\n' const STRIP_SUFFIX = '\n}' const RETAINED_STATE_INSTRUCTIONS = 'Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation.' diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts index a1a2dd1881..d364fb88f0 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts @@ -8,14 +8,15 @@ import { Context } from '@deepseek-ai/cordis' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { expect, it, onTestFinished, vi } from 'vitest' import { mountRuntime } from './setup.ts' +import type { Config } from '../src/index.ts' -async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: 'per-call' | 'persistent-in-memory' = 'persistent-in-memory') { +async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: NonNullable = 'persistent-in-memory') { const root = await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-')) const cwd = join(root, 'workspace') await mkdir(cwd) const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) }) - const { runtime } = await mountRuntime(ctx, { executionMode }, { mode, workspaceRoot: cwd }) + const { runtime } = await mountRuntime(ctx, { executionMode, scriptPersistence: executionMode === 'vm-script' }, { mode, workspaceRoot: cwd }) const owner = {} const run = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner })) return { root, cwd, ctx, runtime, owner, run } @@ -50,25 +51,33 @@ it('refuses reuse under another policy until the owner has been released', async }) // Windows ACL rejects additional roots; its refusal is covered by sandbox-local's runner tests. -it.skipIf(process.platform === 'win32')('requires release before changing a retained environment\'s additional writable roots', async () => { - const { root, cwd, runtime, owner, run } = await setup('workspace-write') +it.skipIf(process.platform === 'win32').each(['persistent-in-memory', 'vm-script'] as const)('keeps %s directory grants until the retained process is released', async (executionMode) => { + const { root, cwd, runtime, owner } = await setup('workspace-write', executionMode) const extra = join(root, 'extra') await mkdir(extra) - expect((await run('var retained = 7; retained')).value).toBe(7) - const spec = runtime.resolve({ - program: 'typeof retained', + const target = join(extra, 'memory.txt') + const roots = [extra] + const policy = { mode: 'workspace-write' as const, workspaceRoot: cwd, additionalWritableRoots: roots } + const write = (content: string, additionalWritableRoots: readonly string[] = []) => runtime.run(runtime.resolve({ + program: `await (await import("node:fs/promises")).writeFile(${JSON.stringify(target)}, ${JSON.stringify(content)})`, bindings: [], stateOwner: owner, - sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [extra] }, - }) - await expect(runtime.run(spec)).rejects.toThrow('release the retained environment') - expect((await run('retained')).value).toBe(7) + sandboxPolicy: { ...policy, additionalWritableRoots }, + })) + + expect((await write('initial', roots)).error).toBeUndefined() + roots[0] = cwd + expect((await write('retained')).error).toBeUndefined() + expect(await readFile(target, 'utf8')).toBe('retained') + await runtime.release(owner) - expect((await runtime.run(spec)).value).toBe('undefined') - const sameRoots = runtime.resolve({ ...spec, program: 'var second = 8; second', sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [extra, extra] } }) - expect((await runtime.run(sameRoots)).value).toBe(8) - const changed = runtime.resolve({ ...spec, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [cwd] } }) - await expect(runtime.run(changed)).rejects.toThrow('release the retained environment') + expect((await write('denied')).sandbox).toMatchObject({ mode: 'workspace-write', denied: true }) + expect((await write('still denied', [extra])).sandbox).toMatchObject({ mode: 'workspace-write', denied: true }) + expect(await readFile(target, 'utf8')).toBe('retained') + + await runtime.release(owner) + expect((await write('renewed', [extra])).error).toBeUndefined() + expect(await readFile(target, 'utf8')).toBe('renewed') }) it('fails closed when confinement is unavailable without spawning Node', async () => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index 0ec2444a46..8edc48c196 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -39,14 +39,14 @@ describe.each([ expect((await run('typeof retained')).value).toBe('undefined') } finally { await runtime.release(owner); await pending } }) - it('keeps a snapshot of caller-owned root arrays and rejects changed grants', async () => { + it('retains variables across additional-root changes until release', async () => { const { run, runtime, owner, cwd } = await setup() const roots = [cwd] const policy = { mode: 'danger-full-access' as const, workspaceRoot: cwd, additionalWritableRoots: roots } expect((await run('var retained = 7; retained', { sandboxPolicy: policy })).value).toBe(7) expect((await run('retained', { sandboxPolicy: { ...policy, additionalWritableRoots: [cwd, cwd] } })).value).toBe(7) roots[0] = tmpdir() - await expect(run('retained', { sandboxPolicy: policy })).rejects.toThrow('release the retained environment') + expect((await run('retained', { sandboxPolicy: policy })).value).toBe(7) await runtime.release(owner) expect((await run('typeof retained', { sandboxPolicy: policy })).value).toBe('undefined') }) diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 4e91831598..65121d087e 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -23,6 +23,9 @@ /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: en: 069de80f17900193 zh: 2833f134c8dcb2e4 +/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: + en: c87ff1fab6cc3a2e + zh: 4970ba31546a0e25 /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index c2ff904ac7..38c8ef41ad 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -52,6 +52,12 @@ For a linked Git worktree, the caller can grant its shared Git directory with `a The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc. +### Registering a session's writable directory + +A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply. + +Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only, and the [Windows subprocess restriction](../sandbox-local/README.md#known-limitations-and-deferred-work) still applies. + ### Switching a session's mode A session's mode can be switched at runtime through a UI policy control or an explicit switch; the switch is recorded in the session log and takes effect on the session's next confined call. The switch survives restart through replay, and each session keeps its own mode — two sessions never see each other's state. A switched session keeps its immutable workspace cwd as the writable boundary. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index f097626989..1369bf3f0a 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -52,6 +52,12 @@ kind: "package-reference" 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。 +### 注册会话的可写目录 + +插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。 + +注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session 与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读,[Windows 子进程限制](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)仍然适用。 + ### 切换会话模式 会话的模式可以在运行时通过 UI 策略控件或显式切换来更改;切换记录在会话日志中,并在该会话的下一次受限调用时生效。切换通过回放跨重启保留,每个会话保持自己的模式——两个会话绝不会看到彼此状态。切换后的会话继续以不可变的工作区 cwd 作为写入边界。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 36fdc1f2e8..edc1110601 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -149,6 +149,8 @@ export class SandboxPolicyService extends Service { readonly workspaceRoot: string /** The frozen extra writable directories shared by every resolved policy, or `undefined` for none. */ readonly additionalWritableRoots: readonly string[] | undefined + /** Independently owned grants for each live Session; never restored from its log. */ + private readonly writableRootRegistrations = new WeakMap>() private sandboxModeProjectionHandle!: ProjectionHandle constructor(ctx: Context, config: Config) { @@ -190,20 +192,55 @@ export class SandboxPolicyService extends Service { * mode outranks the session's last `sandbox/mode` event, which outranks the * deployment default. A session cwd is its workspace-write boundary; the * configured root is the fallback for agentless calls and sessions without a - * cwd. + * cwd. Configured extra roots and this Session's active registrations are + * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request + const registrations = session === undefined ? undefined : this.writableRootRegistrations.get(session) + const additionalWritableRoots = registrations === undefined + ? this.additionalWritableRoots + : resolveAdditionalWritableRoots([...(this.additionalWritableRoots ?? []), ...registrations.values()]) return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), - ...this.additionalWritableRoots === undefined ? {} : { additionalWritableRoots: this.additionalWritableRoots }, + ...additionalWritableRoots === undefined ? {} : { additionalWritableRoots }, ...session === undefined ? {} : { sessionId: session.id }, } } + /** + * Permit writes under one directory in this Session's future workspace-write + * operations. The calling context owns the effect; disposal removes only this + * registration. Other grants to the directory remain effective. Existing + * operations and persistent processes retain their resolved permissions. + * Grants are runtime-only and must be registered again after resume or fork. + * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. + * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @returns the exact Cordis effect disposer for this registration. + * @throws when the directory is relative or contains a NUL byte. + */ + registerWritableRoot(session: Session, directory: string): () => void { + resolveAdditionalWritableRoots([directory]) + const dispose = this.ctx.effect(() => { + let registrations = this.writableRootRegistrations.get(session) + if (registrations === undefined) { + registrations = new Map() + this.writableRootRegistrations.set(session, registrations) + } + const key = Symbol() + registrations.set(key, directory) + return () => { + registrations.delete(key) + if (registrations.size === 0) this.writableRootRegistrations.delete(session) + } + }, 'sandboxPolicy.registerWritableRoot()') + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup preserves the exact Cordis effect disposer + return dispose + } + /** * Read the session override without applying the deployment default. * @param session - session whose log supplies the override. diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 15878013fa..16b6b2edd0 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -182,6 +182,84 @@ describe('SandboxPolicyService', () => { }) }) +describe('session writable-root registrations', () => { + it('isolates live Sessions, preserves configured roots, and detaches resolved policies', async ({ onTestFinished }) => { + const ctx = await mounted({ mode: 'workspace-write', additionalWritableRoots: ['/shared'] }) + onTestFinished(() => ctx.fiber.dispose()) + const first = session('first', '/workspace') + const second = session('second', '/workspace') + const before = ctx.sandboxPolicy.resolve({ session: first }) + const remove = ctx.sandboxPolicy.registerWritableRoot(first, '/memory') + const granted = ctx.sandboxPolicy.resolve({ session: first }) + + expect(granted.additionalWritableRoots).toEqual(['/shared', '/memory']) + expect(Object.isFrozen(granted.additionalWritableRoots)).toBe(true) + expect(before.additionalWritableRoots).toEqual(['/shared']) + expect(ctx.sandboxPolicy.resolve({ session: second }).additionalWritableRoots).toEqual(['/shared']) + expect(ctx.sandboxPolicy.resolve().additionalWritableRoots).toEqual(['/shared']) + const resumed = Session.create(first.id, first.snapshotEvents(), first.header) + expect(ctx.sandboxPolicy.resolve({ session: resumed }).additionalWritableRoots).toEqual(['/shared']) + + remove() + expect(ctx.sandboxPolicy.resolve({ session: first }).additionalWritableRoots).toEqual(['/shared']) + expect(granted.additionalWritableRoots).toEqual(['/shared', '/memory']) + }) + + it('keeps duplicate grants independently owned across plugin unload and explicit disposal', async ({ onTestFinished }) => { + const ctx = await mounted({ mode: 'workspace-write', additionalWritableRoots: ['/shared'] }) + onTestFinished(() => ctx.fiber.dispose()) + const active = session('duplicates') + const fiber = await ctx.plugin({ + inject: ['sandboxPolicy'], + apply(scope: Context) { + scope.sandboxPolicy.registerWritableRoot(active, '/memory') + scope.sandboxPolicy.registerWritableRoot(active, '/plugin-only') + scope.sandboxPolicy.registerWritableRoot(active, '/shared') + }, + }) + const remove = ctx.sandboxPolicy.registerWritableRoot(active, '/memory') + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/memory', '/plugin-only']) + await fiber.dispose() + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/memory']) + remove() + const replacement = ctx.sandboxPolicy.registerWritableRoot(active, '/replacement') + remove() + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/replacement']) + replacement() + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared']) + }) + + it.each(['relative/memory', '/memory\0invalid'])('rejects an invalid directory without installing a grant: %j', async (directory) => { + const ctx = await mounted() + try { + const active = session('invalid') + expect(() => ctx.sandboxPolicy.registerWritableRoot(active, directory)).toThrow('absolute execution-world paths') + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('updates request context without changing read-only policy or the stable prompt', async ({ onTestFinished }) => { + const ctx = await mounted({ mode: 'workspace-write' }) + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + const active = session('context', '/workspace') + const before = await ctx.systemPrompt.assemble({ agent: agentFor(active) }) + const remove = ctx.sandboxPolicy.registerWritableRoot(active, '/memory') + const after = await ctx.systemPrompt.assemble({ agent: agentFor(active) }) + expect(renderPrompt(after)).toBe(renderPrompt(before)) + expect(renderContextSnapshot(after)).toContain('Additional writable directories: ["/memory"]') + setSandboxMode(active, 'read-only') + expect(ctx.sandboxPolicy.resolve({ session: active }).mode).toBe('read-only') + expect(await policyContext(ctx, active)).not.toContain('/memory') + setSandboxMode(active, 'workspace-write') + remove() + expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toBeUndefined() + expect(await policyContext(ctx, active)).not.toContain('/memory') + }) +}) + describe('sandbox:policy request context', () => { async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string; additionalWritableRoots?: string[] } = {}): Promise { const ctx = new Context() diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts new file mode 100644 index 0000000000..7e2605cab6 --- /dev/null +++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts @@ -0,0 +1,23 @@ +/** Session-owned write-grant changes observed through the real request context. */ +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-sandbox-policy' + +/** Snapshot fixture plugin name. */ +export const name = 'session-write-grant' +/** The shared policy owns effect-scoped directory registrations. */ +export const inject = ['sandboxPolicy'] + +/** + * Grant one directory until the first tool result, before the next request. + * @param ctx - fixture lifetime and policy service. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, join(process.cwd(), 'extra')) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'tool/result') dispose() + }) + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a27cb2460f..fe551d41ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12044,6 +12044,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:* version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection diff --git a/snapshots/session/session-sandbox-root/cordis.snapshot.yml b/snapshots/session/session-sandbox-root/cordis.snapshot.yml index 2f1112860a..8477136f86 100644 --- a/snapshots/session/session-sandbox-root/cordis.snapshot.yml +++ b/snapshots/session/session-sandbox-root/cordis.snapshot.yml @@ -45,7 +45,6 @@ config: mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: /tmp - additionalWritableRoots: !!js "[process.cwd() + '/extra']" - id: headless-runner inject: [headlessStartup] @@ -56,6 +55,8 @@ permissionMode: workspace-write - insert: + - id: session-write-grant + name: '../../../packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' config: diff --git a/snapshots/session/session-sandbox-root/cordis.yml b/snapshots/session/session-sandbox-root/cordis.yml index 4c1988708e..f14a1c1ff8 100644 --- a/snapshots/session/session-sandbox-root/cordis.yml +++ b/snapshots/session/session-sandbox-root/cordis.yml @@ -7,7 +7,6 @@ config: mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" workspaceRoot: /tmp - additionalWritableRoots: !!js "[process.cwd() + '/extra']" - id: headless-runner inject: [headlessStartup] @@ -16,3 +15,7 @@ sessionId: !!js ctx.headlessStartup.sessionId json: !!js ctx.headlessStartup.json permissionMode: workspace-write + +- insert: + - id: session-write-grant + name: '../../../packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts' diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl index f14da0e5f7..8290342b9e 100644 --- a/snapshots/session/session-sandbox-root/session.v4.jsonl +++ b/snapshots/session/session-sandbox-root/session.v4.jsonl @@ -15,11 +15,12 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[11],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792249808887,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792249808887,"index":0,"dt":[],"id":"call_session_root","name":"write","args":["{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"]},{"type":"chunk","time":1792249808887,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}},{"type":"chunk","time":1792249808887,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792249808887,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1793015794532,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793015794532,"index":0,"dt":[],"id":"call_session_root","name":"write","args":["{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"]},{"type":"chunk","time":1793015794532,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}},{"type":"chunk","time":1793015794532,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1793015794532,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1792249809020,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792249809020,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792249809020,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792249809020,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1792249809020,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From e032300b4e5453fc33e74d4b9fdcd1c5d96dedcf Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Mon, 26 Oct 2026 21:08:49 +0800 Subject: [PATCH 02/16] fix(sandbox): use current grants after failed PTC startup --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 4 +- docs/subsystems/sandbox.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../ptc-runtime-node/README.i18n.yaml | 8 ++-- .../ptc-runtime/ptc-runtime-node/README.md | 4 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 4 +- .../ptc-runtime-node/src/execution.ts | 7 +++- .../ptc-runtime-node/src/worker.ts | 3 +- .../tests/persistent-sandbox.spec.ts | 37 +++++++++++++++++++ .../sandbox/sandbox-policy/README.i18n.yaml | 8 ++-- packages/sandbox/sandbox-policy/README.md | 4 +- packages/sandbox/sandbox-policy/README.zh.md | 4 +- packages/sandbox/sandbox-policy/src/index.ts | 11 +++--- .../sandbox-policy/tests/policy.spec.ts | 2 +- .../ptc-persistent/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- 19 files changed, 78 insertions(+), 36 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 541f35bf98..2d91981949 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2962,7 +2962,7 @@ export interface Config { - `inject`: `sessionProjections` - `refs`: [`SandboxMode`](subsystems/sandbox.md) -- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts) +- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts) ```ts config-catalog /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ddb66d4024..b586ca6aa2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2964,7 +2964,7 @@ export interface Config { - `inject`: `sessionProjections` - `refs`: [`SandboxMode`](subsystems/sandbox.zh.md) -- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts) +- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts) ```ts config-catalog /** diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7f908ead84..05366c3c58 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -9,8 +9,8 @@ en: 6777e1b1270ec3e1 zh: 22533fb3304c1dd8 /process-sandbox/per-call-policy: - en: fac6f5d32327dfb6 - zh: 1b8e4a1af48f33e8 + en: 53850780ce70e3d8 + zh: 60769c319d778e0e /process-sandbox/wrapped-argv-and-classification-dialects: en: 2ba8930762e92e40 zh: 220f64da1a9e5d57 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 2fe2c319c1..940bbfbf43 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and explicit additional write grants. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and shared additional write grants. Live Session registrations add effect-scoped directories to subsequent resolutions. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs. ```ts type-equiv /** @@ -200,7 +200,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. ```ts cordis-catalog /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 7e54a4a5fd..39cf22cf1c 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和显式额外写入授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和共享的额外写入授权。存活 Session 的注册为后续解析添加 effect 生命周期内的目录授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。 ```ts type-equiv /** @@ -200,7 +200,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a11efdbd50..b8c6b39cb9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2560,7 +2560,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sandboxPolicy', summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', - description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.', + description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.', methods: [ { signature: 'readonly defaultMode: SandboxMode', diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 1ae88f5606..2c4d3e2909 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -9,8 +9,8 @@ en: 7e1a8657a212c70b zh: afb7a8c6a3afd718 /deepseek-ai-dsh-ptc-runtime-node/summary: - en: fd81f3e969a3d8cc - zh: a8211ce7c308dfd2 + en: 7f80d74a7f87eb05 + zh: 6aa31b97ac920ca6 /deepseek-ai-dsh-ptc-runtime-node/table-of-contents: en: d152484eb41ac6b4 zh: 725b47765ee8f30b @@ -24,8 +24,8 @@ en: 2a9fa3292bf07355 zh: db0b5684820915eb /deepseek-ai-dsh-ptc-runtime-node/use-this-package/in-memory-modes: - en: b1d9e8ce46f265ae - zh: d2294ca991f349df + en: c82e89a3221bfa3f + zh: 5b37a48d50291a06 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/deadlines-and-cancellation: en: d383ec7aeab8afec zh: 7c9c49e205fbbcd8 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index a2201ea7b2..745df68fa8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Execute model-written TypeScript with host-provided async functions. The default `per-call` mode runs async function bodies in fresh Node processes; `vm-script` supports top-level await and optionally retains a context per Agent with `scriptPersistence`. Opt-in `persistent-in-memory` retains a REPL per Agent. All modes apply the selected file sandbox policy and return captured logs, lossless JSON, or structured failures. Elapsed deadlines, output bounds and a V8 heap limit constrain execution. Unacknowledged stops and one-shot completion clean up the managed process range; unavailable confinement fails execution. +Execute model-written TypeScript with host-provided async functions. The default `per-call` mode runs async function bodies in fresh Node processes; `vm-script` supports top-level await and optionally retains a context per Agent with `scriptPersistence`. Opt-in `persistent-in-memory` retains a REPL per Agent. All modes apply the file sandbox policy at process launch and return captured logs, lossless JSON, or structured failures. Elapsed deadlines, output bounds and a V8 heap limit constrain execution. Unacknowledged stops and one-shot completion clean up the managed process range; unavailable confinement fails execution. ## Table of Contents @@ -83,7 +83,7 @@ Set `executionMode: vm-script` to evaluate erasable TypeScript through `node:vm` ### In-memory modes -The `persistent-in-memory` mode and Script mode with `scriptPersistence: true` retain state per owner. To select the REPL evaluator, set `executionMode: persistent-in-memory` on the existing `@deepseek-ai/dsh-ptc-runtime-node` plugin. All modes require `fs`, `subprocess`, `sandbox` and `sandboxPolicy` and apply the selected file sandbox policy. Enable it deliberately with a profile patch: +The `persistent-in-memory` mode and Script mode with `scriptPersistence: true` retain state per owner. To select the REPL evaluator, set `executionMode: persistent-in-memory` on the existing `@deepseek-ai/dsh-ptc-runtime-node` plugin. All modes require `fs`, `subprocess`, `sandbox` and `sandboxPolicy` and apply the file sandbox policy at process launch. Enable it deliberately with a profile patch: ```yaml - id: ptc-runtime diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 4a0cbd4bda..ff793aa587 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -执行模型编写的 TypeScript,并提供宿主异步函数。默认的 `per-call` 模式在全新 Node 进程中运行异步函数体;`vm-script` 支持顶层 await,并可通过 `scriptPersistence` 为每个 Agent 保留 context。显式启用的 `persistent-in-memory` 为每个 Agent 保留 REPL。所有模式都应用所选文件沙箱策略,返回捕获日志、无损 JSON 或结构化失败。执行时限、输出上限和 V8 堆上限约束运行。未确认的停止请求与单次执行完成后清理受管进程范围;约束不可用时执行失败。 +执行模型编写的 TypeScript,并提供宿主异步函数。默认的 `per-call` 模式在全新 Node 进程中运行异步函数体;`vm-script` 支持顶层 await,并可通过 `scriptPersistence` 为每个 Agent 保留 context。显式启用的 `persistent-in-memory` 为每个 Agent 保留 REPL。所有模式都在进程启动时应用文件沙箱策略,返回捕获日志、无损 JSON 或结构化失败。执行时限、输出上限和 V8 堆上限约束运行。未确认的停止请求与单次执行完成后清理受管进程范围;约束不可用时执行失败。 ## 目录 @@ -83,7 +83,7 @@ profile 编辑器可从 `@deepseek-ai/dsh-ptc-runtime-node/configuration` 导入 ### 内存保留模式 -`persistent-in-memory` 模式和启用 `scriptPersistence: true` 的 Script 模式都按所有者保留状态。要选择 REPL 求值器,在现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件上设置 `executionMode: persistent-in-memory`。所有模式都需要 `fs`、`subprocess`、`sandbox` 和 `sandboxPolicy`,并应用所选文件沙箱策略。通过配置档补丁显式启用: +`persistent-in-memory` 模式和启用 `scriptPersistence: true` 的 Script 模式都按所有者保留状态。要选择 REPL 求值器,在现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件上设置 `executionMode: persistent-in-memory`。所有模式都需要 `fs`、`subprocess`、`sandbox` 和 `sandboxPolicy`,并在进程启动时应用文件沙箱策略。通过配置档补丁显式启用: ```yaml - id: ptc-runtime diff --git a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts index 5028a9f178..1ae7fde1ab 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts @@ -145,13 +145,16 @@ function resolveMode(config: ResolvedConfig): ExecutionMode { case 'persistent-in-memory': return { format: 'repl', retainsState: true, - executionInstructions: 'The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty. ' + RETAINED_STATE_INSTRUCTIONS, + executionInstructions: 'The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows the environment\'s launch-time sandbox policy; process.env starts empty. ' + RETAINED_STATE_INSTRUCTIONS, stripTypes: stripTypeScriptTypes, } case 'vm-script': { const lifetime = config.scriptPersistence ? 'Variables, functions, and imported modules remain in memory for this Agent across calls.' : 'Each call runs in a fresh Node process.' + const filePolicy = config.scriptPersistence + ? 'Direct filesystem access follows the environment\'s launch-time sandbox policy; process.env starts empty.' + : 'Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty.' const asynchronousFailure = 'Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process' + (config.scriptPersistence ? ' and clears its variables.' : '.') const recovery = config.scriptPersistence @@ -160,7 +163,7 @@ function resolveMode(config: ResolvedConfig): ExecutionMode { return { format: 'script', retainsState: config.scriptPersistence, - executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty. ' + asynchronousFailure + ' Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false.' + recovery, + executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. ' + filePolicy + ' ' + asynchronousFailure + ' Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false.' + recovery, stripTypes: stripTypeScriptTypes, } } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index 2ad0d983e7..17708de189 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -186,7 +186,8 @@ export class ProgramWorker { if (spec.signal?.aborted) onAbort() else { try { evaluation.data.code = this.mode.stripTypes(spec.program) } catch (error: unknown) { - evaluation.finish({ kind: 'exception', message: messageOf(error) }) + // A parse failure before startup has no process state or authority to retain. + evaluation.finish({ kind: 'exception', message: messageOf(error) }, undefined, !this.ready) return await result.promise } if (this.ready) this.dispatch(evaluation) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts index d364fb88f0..62c4e1896a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts @@ -80,6 +80,43 @@ it.skipIf(process.platform === 'win32').each(['persistent-in-memory', 'vm-script expect(await readFile(target, 'utf8')).toBe('renewed') }) +// Windows ACL rejects additional roots before launching a process. +it.skipIf(process.platform === 'win32').each([ + { executionMode: 'persistent-in-memory', change: 'add' }, + { executionMode: 'persistent-in-memory', change: 'remove' }, + { executionMode: 'vm-script', change: 'add' }, + { executionMode: 'vm-script', change: 'remove' }, +] as const)('uses current grants for the first $executionMode process after a syntax error and grant $change', async ({ executionMode, change }) => { + const { root, cwd, ctx, runtime, owner } = await setup('workspace-write', executionMode) + const extra = join(root, 'extra') + await mkdir(extra) + const target = join(extra, 'memory.txt') + const session = ctx.sessions.create(SessionId('first-launch-grants'), { meta: { cwd } }) + const request = { bindings: [], stateOwner: owner } + const remove = change === 'remove' ? ctx.sandboxPolicy.registerWritableRoot(session, extra) : undefined + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + onTestFinished(() => { spawn.mockRestore() }) + expect((await runtime.run(runtime.resolve({ + ...request, program: 'const =', sandboxPolicy: ctx.sandboxPolicy.resolve({ session }), + }))).error?.kind).toBe('exception') + expect(spawn).not.toHaveBeenCalled() + if (change === 'add') ctx.sandboxPolicy.registerWritableRoot(session, extra) + else remove?.() + + const result = await runtime.run(runtime.resolve({ + ...request, sandboxPolicy: ctx.sandboxPolicy.resolve({ session }), + program: `await (await import("node:fs/promises")).writeFile(${JSON.stringify(target)}, "memory")`, + })) + expect(spawn).toHaveBeenCalledTimes(1) + if (change === 'add') { + expect(result.error).toBeUndefined() + expect(await readFile(target, 'utf8')).toBe('memory') + } else { + expect(result.sandbox).toMatchObject({ mode: 'workspace-write', denied: true }) + await expect(readFile(target)).rejects.toMatchObject({ code: 'ENOENT' }) + } +}) + it('fails closed when confinement is unavailable without spawning Node', async () => { const { ctx, run } = await setup() const confine = vi.spyOn(ctx.sandbox, 'confine').mockRejectedValue(new SandboxUnavailableError('read-only')) diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 65121d087e..b236af8e65 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -24,8 +24,8 @@ en: 069de80f17900193 zh: 2833f134c8dcb2e4 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: - en: c87ff1fab6cc3a2e - zh: 4970ba31546a0e25 + en: 504e8c18f3563fd7 + zh: 642d8cc23cbbd1e6 /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 @@ -78,8 +78,8 @@ en: a16a657e0e5145fb zh: 4b19263a728990dc /deepseek-ai-dsh-sandbox-policy/known-limitations-and-deferred-work: - en: 97f355c35ab69187 - zh: d64611e9b17e1ae0 + en: 8582027d60300aaf + zh: c30cfcebf8a63e99 /deepseek-ai-dsh-sandbox-policy/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 38c8ef41ad..ef50dde06f 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply. -Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only, and the [Windows subprocess restriction](../sandbox-local/README.md#known-limitations-and-deferred-work) still applies. +Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, extra grants cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). ### Switching a session's mode @@ -158,7 +158,7 @@ The stable system prompt remains byte-identical across mode changes. A changed f These limits define the policy surface this package provides. They are current package constraints, not a general sandbox comparison or a task backlog. -- **Deployment-scoped extra grants** — additional directories come from configuration; they are not per-session permission settings. Backend support is defined by [sandbox-local](../sandbox-local/README.md). +- **Runtime grants are not persisted** — configured directories are shared across sessions; effect registrations belong to one live Session and must be renewed after restore or fork. Backend support is defined by [sandbox-local](../sandbox-local/README.md). - **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them. - **Temporary areas are deliberately summarized** — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the current context. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 1369bf3f0a..0e13bb9024 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。 -注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session 与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读,[Windows 子进程限制](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)仍然适用。 +注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,额外授权会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 ### 切换会话模式 @@ -158,7 +158,7 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr 这些限制界定了本包提供的策略范围。它们是当前的包级约束,并非通用沙箱对比,也不是待办事项清单。 -- **部署级额外授权**——额外目录来自配置,而非逐会话权限设置。后端支持情况见 [sandbox-local](../sandbox-local/README.zh.md)。 +- **运行时授权不持久化**——配置目录由各会话共享;effect 注册属于一个存活 Session,恢复或派生后必须重新注册。后端支持情况见 [sandbox-local](../sandbox-local/README.zh.md)。 - **仅限文件操作模式**——`SandboxMode` 管控文件操作;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。 - **有意概述临时区域**——强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在当前上下文中如实枚举。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index edc1110601..ddf2889921 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -2,8 +2,9 @@ * The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the * deployment's sandbox fallbacks plus per-session resolution: the file-effect * {@link SandboxMode}, the `workspace-write` root, the explicit extra writable - * directories, and the override kit (the `sandbox/mode` event, its fold, and - * its write path; the fold is the `sandboxMode` session-projection unit + * directories from configuration and live Session registrations, and the override + * kit (the `sandbox/mode` event, its fold, and its write path; the fold is the + * `sandboxMode` session-projection unit * registered here, while the event and write path come from * `./session-mode.ts`). * Before each agent request, the owner also contributes the resolved policy to @@ -50,7 +51,7 @@ function resolveAdditionalWritableRoots(roots: readonly string[] | undefined): r if (roots === undefined || roots.length === 0) return undefined for (const root of roots) { if (!isAbsolute(root) || root.includes('\0')) { - throw new Error('sandbox-policy: additionalWritableRoots entries must be absolute execution-world paths') + throw new Error('sandbox-policy: writable directories must be absolute execution-world paths without NUL bytes') } } return Object.freeze([...new Set(roots)]) @@ -127,8 +128,8 @@ const sandboxModeStateSchema = zod.union([ /** * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment - * default mode, fallback workspace root, and current request-time policy - * section. Tool layers call {@link resolve} for each execution so a session's + * default mode, fallback workspace root, session-scoped directory grants, and + * request-time policy section. Tool layers call {@link resolve} for each execution so a session's * mode log and immutable cwd travel together to every enforcing capability. */ export class SandboxPolicyService extends Service { diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 16b6b2edd0..2de5d84eb2 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -90,7 +90,7 @@ describe('SandboxPolicyService', () => { try { await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SandboxPolicyService, { additionalWritableRoots: ['relative/extra'] })) - .rejects.toThrow('sandbox-policy: additionalWritableRoots entries must be absolute execution-world paths') + .rejects.toThrow('sandbox-policy: writable directories must be absolute execution-world paths without NUL bytes') expect(ctx.get('sandboxPolicy')).toBeUndefined() } finally { await ctx.fiber.dispose() diff --git a/snapshots/session/ptc-persistent/tool-schemas.expected.json b/snapshots/session/ptc-persistent/tool-schemas.expected.json index ba5d09f8c9..d28cfa0acf 100644 --- a/snapshots/session/ptc-persistent/tool-schemas.expected.json +++ b/snapshots/session/ptc-persistent/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/ptc-script-retained/tool-schemas.expected.json b/snapshots/session/ptc-script-retained/tool-schemas.expected.json index dcab3a85ff..c3e65ba191 100644 --- a/snapshots/session/ptc-script-retained/tool-schemas.expected.json +++ b/snapshots/session/ptc-script-retained/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { From f8633966f59c409ebe15eb017b5e83390fd8f3f9 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Tue, 27 Oct 2026 11:37:19 +0800 Subject: [PATCH 03/16] fix(sandbox): skip non-directory writable roots at execution --- ...ssion-writable-directory-effects.i18n.yaml | 4 +-- ...0-26-session-writable-directory-effects.md | 2 ++ ...6-session-writable-directory-effects.zh.md | 2 ++ docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/sandbox.md | 6 ++-- docs/subsystems/sandbox.zh.md | 6 ++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 12 ++++++++ .../sandbox/sandbox-local/README.i18n.yaml | 8 +++--- packages/sandbox/sandbox-local/README.md | 4 +-- packages/sandbox/sandbox-local/README.zh.md | 4 +-- packages/sandbox/sandbox-local/src/index.ts | 4 +-- .../sandbox-local/tests/extra-roots.ts | 7 +++-- .../sandbox/sandbox-local/tests/local.spec.ts | 7 ++++- .../sandbox/sandbox-policy/README.i18n.yaml | 8 +++--- packages/sandbox/sandbox-policy/README.md | 6 ++-- packages/sandbox/sandbox-policy/README.zh.md | 6 ++-- packages/sandbox/sandbox-policy/src/index.ts | 4 +-- 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 | 4 +-- packages/sandbox/sandbox/src/roots.ts | 15 ++++++---- packages/sandbox/sandbox/tests/roots.spec.ts | 28 ++++++++++++++----- .../tests/fixtures/session-write-grant.ts | 10 +++++-- 26 files changed, 103 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml index 77a0e4417c..4800f8dbee 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -9,8 +9,8 @@ en: 2a32db1f27371403 zh: 89cf04705ef66769 /agent-note-session-writable-directory-effects/decision: - en: a6618e4ee649c198 - zh: 5756dad5dd01dede + en: 68f9498fd4d92145 + zh: 34aac1aa8bf96b91 /agent-note-session-writable-directory-effects/alternatives-considered: en: dad77744dea17bde zh: bb3838f9f9099944 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md index 1292e43178..63dbe7acdc 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -14,6 +14,8 @@ The sandbox-policy service owns effect-scoped directory registrations keyed by l Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged. +Missing and non-directory extra paths grant nothing for that execution, so an optional plugin directory cannot block unrelated workspace operations. Enforcement checks them again on each call; a directory created later becomes eligible without re-registration. Symlinked directories remain rejected. + ## Alternatives considered **Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service. diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md index 8c934b20ff..934105efc0 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -14,6 +14,8 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。 +不存在或不是目录的额外路径在该次执行中不授予权限,因此插件的可选目录不会阻塞无关的工作区操作。执行限制时每次重新检查这些路径;稍后创建的目录无需重新注册即可获得授权。符号链接目录仍被拒绝。 + ## 考虑过的替代方案 **共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2d91981949..44bf462594 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2985,7 +2985,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under + * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b586ca6aa2..414f7f9b84 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2987,7 +2987,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under + * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 940bbfbf43..60cf939f09 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -55,8 +55,8 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host rejects missing, non-directory, and - * symlinked entries on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host skips missing and non-directory entries + * and rejects symlinked directories on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 39cf22cf1c..1b0b00ed34 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -55,8 +55,8 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host rejects missing, non-directory, and - * symlinked entries on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host skips missing and non-directory entries + * and rejects symlinked directories on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b8c6b39cb9..a95a291550 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2586,7 +2586,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'registerWritableRoot(session: Session, directory: string): () => void', description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.', - parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.' }], + parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.' }], returns: 'the exact Cordis effect disposer for this registration.', throws: ['when the directory is relative or contains a NUL byte.'], }, diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index e0a00e2adb..80894f820f 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -117,6 +117,18 @@ describe('workspace-write containment', () => { expect(await readFile(path, 'utf8')).toBe('updated') }) + it('ignores non-directory grants without blocking workspace or granted-directory writes', async () => { + const file = join(outside, 'file.txt') + await writeFile(file, 'file') + const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace, + additionalWritableRoots: [outside, join(base, 'missing'), file, join(file, 'child')] } + for (const directory of [workspace, outside]) { + const path = join(directory, 'allowed.txt') + await fs.writeText(await target(path), 'allowed', undefined, undefined, policy) + expect(await readFile(path, 'utf8')).toBe('allowed') + } + }) + it('rejects an extra directory replaced by a symlink to an ungranted sibling', async () => { const extra = join(workspace, 'extra') await mkdir(extra) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 32a828bd3a..64c10233ec 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7282c9ec77fd6f01 zh: 10e385a9489105e3 /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery: - en: 9b648f1bfdd55e76 - zh: d9265bdf2e272581 + en: 5f0db8dd0b75afb7 + zh: b523950be1ac3860 /deepseek-ai-dsh-sandbox-local/understand-the-implementation: en: 15db6ec303151ccc zh: 91f62c1f64192f92 @@ -54,8 +54,8 @@ en: e290ccc9b3c92c08 zh: 469589ac12cdadab /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work: - en: 7cd5c6a4cc971803 - zh: 4e4f495b654d95c5 + en: 38756b82fe0cd48c + zh: 441cc45ebe5b51fd /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index de7a26c4bc..9c08830c4f 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly. -Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Invalid extra directories raise `SANDBOX_INVALID_ROOT` before the command starts. +Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing and non-directory paths are skipped at execution time. Symlinked directories and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. ----- @@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. -- **Windows extra roots** — Windows ACL rejects every workspace-write subprocess carrying extra directories with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations. +- **Windows extra roots** — Windows ACL rejects workspace-write subprocesses with existing writable directories beyond the workspace with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations. - **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index bb03e87ea6..08f2821cb1 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。 -Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。无效的额外目录会在命令启动前触发 `SANDBOX_INVALID_ROOT`。 +Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。执行时跳过不存在或不是目录的路径。符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。 ----- @@ -130,7 +130,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝所有携带额外目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。 +- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝授权工作区以外现存目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。 - **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index acf4b4e738..5f46c01b51 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -34,7 +34,7 @@ import { } from '@deepseek-ai/node-addon-system/landlock-run' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath, workspaceWritableRoots } 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, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' @@ -367,7 +367,7 @@ export class LocalSandboxProvider extends SandboxProvider { * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { - if (policy.mode === 'workspace-write' && (policy.additionalWritableRoots?.length ?? 0) > 0) { + if (workspaceWritableRoots(policy).length > 1) { throw new SandboxUnsupportedPolicyError('Windows ACL confinement does not support additional writable directories; remove the extra grants or use a supported host') } const sessionId = policy.sessionId diff --git a/packages/sandbox/sandbox-local/tests/extra-roots.ts b/packages/sandbox/sandbox-local/tests/extra-roots.ts index ba4d40a773..79229fd64c 100644 --- a/packages/sandbox/sandbox-local/tests/extra-roots.ts +++ b/packages/sandbox/sandbox-local/tests/extra-roots.ts @@ -1,6 +1,6 @@ /** Native backend checks for explicit directory grants and their denial cases. */ import { spawnSync } from 'node:child_process' -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { expect } from 'vitest' @@ -29,7 +29,10 @@ export async function checkExtraWrites(provider: LocalSandboxProvider, directory const workspaceRoot = realpathSync.native(await directory(homedir())) const extra = realpathSync.native(await directory(homedir())) const outside = realpathSync.native(await directory(homedir())) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot, additionalWritableRoots: [extra] } + const file = join(workspaceRoot, 'file.txt') + writeFileSync(file, 'file') + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot, + additionalWritableRoots: [extra, join(workspaceRoot, 'missing'), file, join(file, 'child')] } const allowed = join(extra, 'allowed.txt') const result = await run(provider, `printf allowed > ${quote(allowed)}`, policy) expect(result.status, result.stderr).toBe(0) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index e80b7e8476..05e4abfb3f 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -436,8 +436,13 @@ describe('the windows-acl probe (runner invocation contract)', () => { probeWindowsAcl: () => true, windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], }) + const extra = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-extra-acl-'))) + tempDirs.push(extra) + const file = join(extra, 'file.txt') + writeFileSync(file, 'file') try { - await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: ['/extra'] })) + await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).resolves.toBeDefined() + await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })) .rejects.toThrow('Windows ACL confinement does not support additional writable directories') await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined() } finally { diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index b236af8e65..44ab7211d2 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,11 +21,11 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: 069de80f17900193 - zh: 2833f134c8dcb2e4 + en: b1f04aadeb80f810 + zh: 3be13588a912b8a7 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: - en: 504e8c18f3563fd7 - zh: 642d8cc23cbbd1e6 + en: adbc1838f04d9f53 + zh: 83a0a76cc3b36576 /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index ef50dde06f..ba59138092 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -48,15 +48,15 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd | | `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them | -For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, files, and symlinked roots, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. +For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host skips missing paths and non-directories on each call; they grant nothing for that execution. It rejects symlinked directories, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc. ### Registering a session's writable directory -A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply. +A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. The directory may be registered before it exists; subsequent executions grant it once it is a directory on the execution host. The same path checks as configured roots apply. -Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, extra grants cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). +Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). ### Switching a session's mode diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 0e13bb9024..0fdf06fe20 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -48,15 +48,15 @@ kind: "package-reference" | `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 | -对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机拒绝不存在的路径、文件和符号链接根目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 +对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时跳过不存在或不是目录的路径;这些路径在该次执行中不授予权限。它拒绝符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。 ### 注册会话的可写目录 -插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。 +插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。目录尚不存在时也可以注册;当它在执行主机上成为目录后,后续执行便会授予权限。路径检查与配置根目录相同。 -注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,额外授权会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 +注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 ### 切换会话模式 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index ddf2889921..e18b9becc1 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -105,7 +105,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under + * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] @@ -219,7 +219,7 @@ export class SandboxPolicyService extends Service { * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants. + * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index ffd7e69baf..1953226bc3 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -45,8 +45,8 @@ en: 1b418d41f571ab3d zh: 62b289cdd6f4d272 /deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots: - en: 00dcc13597416429 - zh: eaff9233d0611ad0 + en: a5e34cecd6d0d5ce + zh: 5b16bd952f975c44 /deepseek-ai-dsh-sandbox/further-exploration: en: 33d09277fb15d59f zh: 920ffe66671b56f3 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 7014cc4730..617e28f351 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o ### Writable roots -`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. See the [mode definitions](../../../docs/subsystems/sandbox.md). +`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots, skip extra paths that are missing or are not directories, and reject directories that resolve through symlinks. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md). diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index bc94b7f090..a32213762f 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -100,7 +100,7 @@ kind: "package-reference" ### 可写根目录 -`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 +`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,跳过不存在或不是目录的额外路径,并拒绝经过符号链接解析的目录。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 0fa3183987..d8b6d99b80 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -44,8 +44,8 @@ export interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host rejects missing, non-directory, and - * symlinked entries on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host skips missing and non-directory entries + * and rejects symlinked directories on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index b2f1c39c24..276ef5f123 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -27,17 +27,19 @@ export class SandboxInvalidRootError extends HarnessError { } } -/** Reject mutable path aliases before they can redirect an explicit grant. */ -function additionalRoot(path: string): string { +/** Resolve existing directories without allowing mutable aliases to redirect grants. */ +function additionalRoot(path: string): string | undefined { let physical: string let directory: boolean try { physical = realpathSync.native(path) directory = statSync(physical).isDirectory() } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') return undefined throw new SandboxInvalidRootError(path, `cannot read the directory: ${String(error)}`) } - if (!directory) throw new SandboxInvalidRootError(path, 'expected an existing directory') + if (!directory) return undefined if (physical !== resolve(path)) { throw new SandboxInvalidRootError(path, `use the physical directory path ${JSON.stringify(physical)}; symlinked grants are not supported`) } @@ -69,7 +71,7 @@ export function canonicalPath(path: string): string { /** * The workspace-owned writable roots — the policy's workspace root plus its - * explicit extra directories, as a canonical, deduplicated allow-list. Both + * existing extra directories, as a canonical, deduplicated allow-list. Both * the workspace and every extra root must be granted together by every * backend; this is the one derivation of that set, kept separate from the * platform temp policy each backend documents for itself (Seatbelt and the fs @@ -77,11 +79,12 @@ export function canonicalPath(path: string): string { * grants). * @param policy - the file-effect policy to derive the allow-list from. * @returns the canonical workspace-owned roots; empty under `read-only`. - * @throws {SandboxInvalidRootError} An extra root is missing, is not a directory, or uses a symlink. + * @throws {SandboxInvalidRootError} An extra root cannot be inspected or uses a symlink; missing and non-directory entries are skipped. */ export function workspaceWritableRoots(policy: SandboxExecutionPolicy): string[] { if (policy.mode !== 'workspace-write') return [] - return [...new Set([canonicalPath(policy.workspaceRoot), ...(policy.additionalWritableRoots ?? []).map(additionalRoot)])] + const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot).filter(root => root !== undefined) + return [...new Set([canonicalPath(policy.workspaceRoot), ...additional])] } /** diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index b7b5636882..8f8864f57e 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -5,10 +5,10 @@ * additional set stays one shared derivation across backend dialects. */ -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } 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 { canonicalPath, workspaceWritableRoots, writableRoots } from '@deepseek-ai/dsh-sandbox' /** Every temp root created by this file, removed after each test. */ @@ -52,15 +52,29 @@ describe('workspaceWritableRoots', () => { .toThrow('use the physical directory path') }) - it('rejects missing and non-directory extra roots before granting access', () => { + it('skips non-directory extra roots and checks them again on each call', () => { const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-'))) roots.push(ws) const file = join(ws, 'file.txt') + const extra = join(ws, 'extra') writeFileSync(file, 'file') - expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [file] })) - .toThrow('expected an existing directory') - expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [join(ws, 'missing')] })) - .toThrow('cannot read the directory') + const policy = { mode: 'workspace-write' as const, workspaceRoot: ws, + additionalWritableRoots: [file, join(file, 'child'), extra] } + expect(workspaceWritableRoots(policy)).toEqual([ws]) + mkdirSync(extra) + expect(workspaceWritableRoots(policy)).toEqual([ws, extra]) + rmSync(extra, { recursive: true }) + expect(workspaceWritableRoots(policy)).toEqual([ws]) + }) + + it('reports inspection failures that do not establish a missing or non-directory path', () => { + const inspect = vi.spyOn(realpathSync, 'native').mockImplementationOnce(() => { + throw Object.assign(new Error('access denied'), { code: 'EACCES' }) + }) + try { + expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: '/workspace', additionalWritableRoots: ['/private'] })) + .toThrow('cannot read the directory') + } finally { inspect.mockRestore() } }) it('read-only grants nothing even when extra roots are carried on the policy', () => { diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts index 7e2605cab6..96087efe88 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts @@ -10,14 +10,18 @@ export const name = 'session-write-grant' export const inject = ['sandboxPolicy'] /** - * Grant one directory until the first tool result, before the next request. + * Grant directories until the first tool result, before the next request. * @param ctx - fixture lifetime and policy service. */ export function apply(ctx: Context): void { ctx.on('agent/created', ({ agent }) => { - const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, join(process.cwd(), 'extra')) + const extra = join(process.cwd(), 'extra') + const disposers = [extra, join(extra, 'missing'), join(extra, 'keep.txt')] + .map(directory => ctx.sandboxPolicy.registerWritableRoot(agent.session, directory)) ctx.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'tool/result') dispose() + if (session === agent.session && event.type === 'tool/result') { + for (const dispose of disposers) dispose() + } }) }) } From 438e84d7ef3b8a6d74f0817c50fdc963edd573ae Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Tue, 3 Nov 2026 15:30:10 +0800 Subject: [PATCH 04/16] test(sandbox): retain agent identity in refreshed policy context --- snapshots/session/session-sandbox-root/session.v4.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl index 8290342b9e..c003c74d67 100644 --- a/snapshots/session/session-sandbox-root/session.v4.jsonl +++ b/snapshots/session/session-sandbox-root/session.v4.jsonl @@ -20,7 +20,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From a449c9d734bfe68c9560efe172212a497e6c4d48 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Tue, 3 Nov 2026 17:03:30 +0800 Subject: [PATCH 05/16] test(ptc): refresh persistent import tool description --- snapshots/session/ptc-script-imports/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/ptc-script-imports/tool-schemas.expected.json b/snapshots/session/ptc-script-imports/tool-schemas.expected.json index dcab3a85ff..c3e65ba191 100644 --- a/snapshots/session/ptc-script-imports/tool-schemas.expected.json +++ b/snapshots/session/ptc-script-imports/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { From e6cd6fdc617ed70ac2d121cda9d095c9c93f1dfc Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 4 Nov 2026 14:18:39 +0800 Subject: [PATCH 06/16] fix(sandbox): reject invalid writable directory grants --- ...ssion-writable-directory-effects.i18n.yaml | 4 +-- ...0-26-session-writable-directory-effects.md | 2 +- ...6-session-writable-directory-effects.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/sandbox.md | 6 ++--- docs/subsystems/sandbox.zh.md | 6 ++--- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 15 ++++++----- .../sandbox/sandbox-local/README.i18n.yaml | 4 +-- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- .../sandbox-local/tests/extra-roots.ts | 6 ++--- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox/sandbox-policy/README.i18n.yaml | 8 +++--- packages/sandbox/sandbox-policy/README.md | 4 +-- packages/sandbox/sandbox-policy/README.zh.md | 4 +-- packages/sandbox/sandbox-policy/src/index.ts | 4 +-- 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 | 4 +-- packages/sandbox/sandbox/src/roots.ts | 18 ++++++------- packages/sandbox/sandbox/tests/roots.spec.ts | 26 +++++++++++++++---- .../tests/fixtures/session-write-grant.ts | 5 ++-- 25 files changed, 75 insertions(+), 63 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml index 4800f8dbee..bcc2fe33cb 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -9,8 +9,8 @@ en: 2a32db1f27371403 zh: 89cf04705ef66769 /agent-note-session-writable-directory-effects/decision: - en: 68f9498fd4d92145 - zh: 34aac1aa8bf96b91 + en: af67969841bedb67 + zh: edabdfaa30bcadd1 /agent-note-session-writable-directory-effects/alternatives-considered: en: dad77744dea17bde zh: bb3838f9f9099944 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md index 63dbe7acdc..1608b93010 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -14,7 +14,7 @@ The sandbox-policy service owns effect-scoped directory registrations keyed by l Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged. -Missing and non-directory extra paths grant nothing for that execution, so an optional plugin directory cannot block unrelated workspace operations. Enforcement checks them again on each call; a directory created later becomes eligible without re-registration. Symlinked directories remain rejected. +Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md index 934105efc0..3cc4f01c3f 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -14,7 +14,7 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。 -不存在或不是目录的额外路径在该次执行中不授予权限,因此插件的可选目录不会阻塞无关的工作区操作。执行限制时每次重新检查这些路径;稍后创建的目录无需重新注册即可获得授权。符号链接目录仍被拒绝。 +不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 44bf462594..2d91981949 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2985,7 +2985,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under + * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 414f7f9b84..b586ca6aa2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2987,7 +2987,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under + * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 60cf939f09..a3539c85c2 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -55,8 +55,8 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host skips missing and non-directory entries - * and rejects symlinked directories on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host rejects missing, non-directory, and + * symlinked entries on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. + * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 1b0b00ed34..78b8b63113 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -55,8 +55,8 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host skips missing and non-directory entries - * and rejects symlinked directories on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host rejects missing, non-directory, and + * symlinked entries on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. + * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a95a291550..5c112c83cf 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2586,7 +2586,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'registerWritableRoot(session: Session, directory: string): () => void', description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.', - parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.' }], + parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.' }], returns: 'the exact Cordis effect disposer for this registration.', throws: ['when the directory is relative or contains a NUL byte.'], }, diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 80894f820f..1134b27cfb 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -117,15 +117,16 @@ describe('workspace-write containment', () => { expect(await readFile(path, 'utf8')).toBe('updated') }) - it('ignores non-directory grants without blocking workspace or granted-directory writes', async () => { + it('rejects invalid grants before workspace writes', async () => { const file = join(outside, 'file.txt') await writeFile(file, 'file') - const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace, - additionalWritableRoots: [outside, join(base, 'missing'), file, join(file, 'child')] } - for (const directory of [workspace, outside]) { - const path = join(directory, 'allowed.txt') - await fs.writeText(await target(path), 'allowed', undefined, undefined, policy) - expect(await readFile(path, 'utf8')).toBe('allowed') + const path = join(workspace, 'blocked.txt') + for (const invalid of [join(base, 'missing'), file, join(file, 'child')]) { + const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace, + additionalWritableRoots: [outside, invalid] } + await expect(fs.writeText(await target(path), 'blocked', undefined, undefined, policy)) + .rejects.toMatchObject({ code: 'SANDBOX_INVALID_ROOT' }) + expect(existsSync(path)).toBe(false) } }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 64c10233ec..56f69b41d7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7282c9ec77fd6f01 zh: 10e385a9489105e3 /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery: - en: 5f0db8dd0b75afb7 - zh: b523950be1ac3860 + en: f7033dc3f9c073de + zh: e667973ac2e40ad0 /deepseek-ai-dsh-sandbox-local/understand-the-implementation: en: 15db6ec303151ccc zh: 91f62c1f64192f92 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 9c08830c4f..dfe65511b6 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly. -Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing and non-directory paths are skipped at execution time. Symlinked directories and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. +Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. ----- diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 08f2821cb1..152110d5f6 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。 -Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。执行时跳过不存在或不是目录的路径。符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。 +Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。 ----- diff --git a/packages/sandbox/sandbox-local/tests/extra-roots.ts b/packages/sandbox/sandbox-local/tests/extra-roots.ts index 79229fd64c..0248931af7 100644 --- a/packages/sandbox/sandbox-local/tests/extra-roots.ts +++ b/packages/sandbox/sandbox-local/tests/extra-roots.ts @@ -1,6 +1,6 @@ /** Native backend checks for explicit directory grants and their denial cases. */ import { spawnSync } from 'node:child_process' -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { expect } from 'vitest' @@ -29,10 +29,8 @@ export async function checkExtraWrites(provider: LocalSandboxProvider, directory const workspaceRoot = realpathSync.native(await directory(homedir())) const extra = realpathSync.native(await directory(homedir())) const outside = realpathSync.native(await directory(homedir())) - const file = join(workspaceRoot, 'file.txt') - writeFileSync(file, 'file') const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot, - additionalWritableRoots: [extra, join(workspaceRoot, 'missing'), file, join(file, 'child')] } + additionalWritableRoots: [extra] } const allowed = join(extra, 'allowed.txt') const result = await run(provider, `printf allowed > ${quote(allowed)}`, policy) expect(result.status, result.stderr).toBe(0) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 05e4abfb3f..36eb46c9c1 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -441,7 +441,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { const file = join(extra, 'file.txt') writeFileSync(file, 'file') try { - await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).resolves.toBeDefined() + await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory') await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })) .rejects.toThrow('Windows ACL confinement does not support additional writable directories') await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined() diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 44ab7211d2..3638e6ac9f 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,11 +21,11 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: b1f04aadeb80f810 - zh: 3be13588a912b8a7 + en: c7e924a923bfac72 + zh: fdaf930a5800ffa6 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: - en: adbc1838f04d9f53 - zh: 83a0a76cc3b36576 + en: 169b82d5392ec91a + zh: e0ef9428cb794f9c /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index ba59138092..431c140934 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -48,13 +48,13 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd | | `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them | -For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host skips missing paths and non-directories on each call; they grant nothing for that execution. It rejects symlinked directories, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. +For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc. ### Registering a session's writable directory -A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. The directory may be registered before it exists; subsequent executions grant it once it is a directory on the execution host. The same path checks as configured roots apply. +A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply. Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 0fdf06fe20..f2d3a88632 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -48,13 +48,13 @@ kind: "package-reference" | `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 | -对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时跳过不存在或不是目录的路径;这些路径在该次执行中不授予权限。它拒绝符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 +对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。 ### 注册会话的可写目录 -插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。目录尚不存在时也可以注册;当它在执行主机上成为目录后,后续执行便会授予权限。路径检查与配置根目录相同。 +插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。 注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index e18b9becc1..064c14b070 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -105,7 +105,7 @@ export interface Config { * (default: none). The supervisor names them explicitly; there is no * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while - * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under + * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under * `read-only`. */ additionalWritableRoots?: string[] @@ -219,7 +219,7 @@ export class SandboxPolicyService extends Service { * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. - * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories. + * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. * @throws when the directory is relative or contains a NUL byte. */ diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 1953226bc3..095aa685e5 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -45,8 +45,8 @@ en: 1b418d41f571ab3d zh: 62b289cdd6f4d272 /deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots: - en: a5e34cecd6d0d5ce - zh: 5b16bd952f975c44 + en: b5001b6dc9a0d258 + zh: 08020a052df6856c /deepseek-ai-dsh-sandbox/further-exploration: en: 33d09277fb15d59f zh: 920ffe66671b56f3 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 617e28f351..d5de61a78c 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o ### Writable roots -`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots, skip extra paths that are missing or are not directories, and reject directories that resolve through symlinks. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md). +`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md). diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index a32213762f..f4b208450e 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -100,7 +100,7 @@ kind: "package-reference" ### 可写根目录 -`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,跳过不存在或不是目录的额外路径,并拒绝经过符号链接解析的目录。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 +`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index d8b6d99b80..0fa3183987 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -44,8 +44,8 @@ export interface SandboxExecutionPolicy { workspaceRoot: string /** * Extra physical absolute directories `workspace-write` may also write under. - * Absence means none. The enforcing host skips missing and non-directory entries - * and rejects symlinked directories on each call. Remote paths are checked on the remote host. + * Absence means none. The enforcing host rejects missing, non-directory, and + * symlinked entries on each call. Remote paths are checked on the remote host. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index 276ef5f123..c0d908de1a 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -15,7 +15,7 @@ import { realpathSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { relative } from 'node:path' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SandboxExecutionPolicy } from './index.ts' @@ -27,20 +27,18 @@ export class SandboxInvalidRootError extends HarnessError { } } -/** Resolve existing directories without allowing mutable aliases to redirect grants. */ -function additionalRoot(path: string): string | undefined { +/** Resolve existing directories, rejecting aliases that differ under the host's path comparison. */ +function additionalRoot(path: string): string { let physical: string let directory: boolean try { physical = realpathSync.native(path) directory = statSync(physical).isDirectory() } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT' || code === 'ENOTDIR') return undefined throw new SandboxInvalidRootError(path, `cannot read the directory: ${String(error)}`) } - if (!directory) return undefined - if (physical !== resolve(path)) { + if (!directory) throw new SandboxInvalidRootError(path, 'expected an existing directory') + if (relative(path, physical) !== '') { throw new SandboxInvalidRootError(path, `use the physical directory path ${JSON.stringify(physical)}; symlinked grants are not supported`) } return physical @@ -71,7 +69,7 @@ export function canonicalPath(path: string): string { /** * The workspace-owned writable roots — the policy's workspace root plus its - * existing extra directories, as a canonical, deduplicated allow-list. Both + * explicit extra directories, as a canonical, deduplicated allow-list. Both * the workspace and every extra root must be granted together by every * backend; this is the one derivation of that set, kept separate from the * platform temp policy each backend documents for itself (Seatbelt and the fs @@ -79,11 +77,11 @@ export function canonicalPath(path: string): string { * grants). * @param policy - the file-effect policy to derive the allow-list from. * @returns the canonical workspace-owned roots; empty under `read-only`. - * @throws {SandboxInvalidRootError} An extra root cannot be inspected or uses a symlink; missing and non-directory entries are skipped. + * @throws {SandboxInvalidRootError} An extra root cannot be inspected, is not a directory, or uses a symlink. */ export function workspaceWritableRoots(policy: SandboxExecutionPolicy): string[] { if (policy.mode !== 'workspace-write') return [] - const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot).filter(root => root !== undefined) + const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot) return [...new Set([canonicalPath(policy.workspaceRoot), ...additional])] } diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index 8f8864f57e..27e2a0ae40 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -52,19 +52,35 @@ describe('workspaceWritableRoots', () => { .toThrow('use the physical directory path') }) - it('skips non-directory extra roots and checks them again on each call', () => { + it.skipIf(process.platform !== 'win32')('accepts Windows casing and separator differences and retains the canonical directory identity', () => { + const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-'))) + roots.push(ws) + const extra = join(ws, 'Memory') + mkdirSync(extra) + const alias = join(ws, 'memory').replaceAll('\\', '/') + const physical = realpathSync.native(extra) + expect(alias).not.toBe(physical) + const grants = workspaceWritableRoots({ + mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [alias, extra], + }) + expect(grants).toEqual([ws, physical]) + }) + + it('rejects invalid extra roots and checks them again on each call', () => { const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-'))) roots.push(ws) const file = join(ws, 'file.txt') const extra = join(ws, 'extra') writeFileSync(file, 'file') - const policy = { mode: 'workspace-write' as const, workspaceRoot: ws, - additionalWritableRoots: [file, join(file, 'child'), extra] } - expect(workspaceWritableRoots(policy)).toEqual([ws]) + for (const path of [file, join(file, 'child'), extra]) { + expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [path] })) + .toThrow('Invalid additional writable directory') + } + const policy = { mode: 'workspace-write' as const, workspaceRoot: ws, additionalWritableRoots: [extra] } mkdirSync(extra) expect(workspaceWritableRoots(policy)).toEqual([ws, extra]) rmSync(extra, { recursive: true }) - expect(workspaceWritableRoots(policy)).toEqual([ws]) + expect(() => workspaceWritableRoots(policy)).toThrow('cannot read the directory') }) it('reports inspection failures that do not establish a missing or non-directory path', () => { diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts index 96087efe88..e4d21d0d8b 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts @@ -16,11 +16,10 @@ export const inject = ['sandboxPolicy'] export function apply(ctx: Context): void { ctx.on('agent/created', ({ agent }) => { const extra = join(process.cwd(), 'extra') - const disposers = [extra, join(extra, 'missing'), join(extra, 'keep.txt')] - .map(directory => ctx.sandboxPolicy.registerWritableRoot(agent.session, directory)) + const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, extra) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'tool/result') { - for (const dispose of disposers) dispose() + dispose() } }) }) From 27fccbccbc78887bf03266bd547f4ee501262ee9 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 4 Nov 2026 14:32:21 +0800 Subject: [PATCH 07/16] test(ptc): align new retained-mode snapshots with launch policy --- snapshots/session/ptc-reset/tool-schemas.expected.json | 2 +- .../session/ptc-timeout-fallback/tool-schemas.expected.json | 2 +- snapshots/session/ptc-timeout/tool-schemas.expected.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/session/ptc-reset/tool-schemas.expected.json b/snapshots/session/ptc-reset/tool-schemas.expected.json index dcab3a85ff..c3e65ba191 100644 --- a/snapshots/session/ptc-reset/tool-schemas.expected.json +++ b/snapshots/session/ptc-reset/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json b/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json index 1756617b78..fdaea15490 100644 --- a/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json +++ b/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/ptc-timeout/tool-schemas.expected.json b/snapshots/session/ptc-timeout/tool-schemas.expected.json index 1756617b78..fdaea15490 100644 --- a/snapshots/session/ptc-timeout/tool-schemas.expected.json +++ b/snapshots/session/ptc-timeout/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", + "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.", "parameters": { "type": "object", "properties": { From 61d78c727f836a23a585edbb3d018fa36fa56aa6 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 4 Nov 2026 15:14:30 +0800 Subject: [PATCH 08/16] test(sandbox): refresh revoked-grant context with agent names --- snapshots/session/session-sandbox-root/session.v4.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl index c003c74d67..26571dd426 100644 --- a/snapshots/session/session-sandbox-root/session.v4.jsonl +++ b/snapshots/session/session-sandbox-root/session.v4.jsonl @@ -20,7 +20,7 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 69e6199cb7219344a3667426134dc66f2e604081 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 4 Nov 2026 16:06:17 +0800 Subject: [PATCH 09/16] test(sandbox): canonicalize roots allocated under home --- packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts | 4 ++-- .../ptc-runtime-node/tests/persistent-sandbox.spec.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 1134b27cfb..fa9b48d873 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, parse } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -42,7 +42,7 @@ beforeEach(async ({ onTestFinished }) => { // Both siblings must be outside automatic temp grants for containment denials to be meaningful. const directory = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-fssbx-')) onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) - base = directory + base = realpathSync.native(directory) assertWorkspaceOutsideTemp(base) workspace = join(base, 'ws') outside = join(base, 'out') diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts index 62c4e1896a..8461a3152c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { realpathSync } from 'node:fs' import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' @@ -11,7 +12,7 @@ import { mountRuntime } from './setup.ts' import type { Config } from '../src/index.ts' async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: NonNullable = 'persistent-in-memory') { - const root = await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-')) + const root = realpathSync.native(await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-'))) const cwd = join(root, 'workspace') await mkdir(cwd) const ctx = new Context() From a63ba54f306fc30fcaa7e4b6f73a1f7e0b59ccaa Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Tue, 27 Oct 2026 16:23:55 +0800 Subject: [PATCH 10/16] feat(sandbox): support writable-directory sets on Windows --- ...ssion-writable-directory-effects.i18n.yaml | 8 +- ...0-26-session-writable-directory-effects.md | 4 +- ...6-session-writable-directory-effects.zh.md | 4 +- ...ows-acl-restricted-token-sandbox.i18n.yaml | 8 +- ...08-windows-acl-restricted-token-sandbox.md | 4 +- ...windows-acl-restricted-token-sandbox.zh.md | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 8 +- packages/sandbox/sandbox-local/README.md | 4 +- packages/sandbox/sandbox-local/README.zh.md | 4 +- packages/sandbox/sandbox-local/src/index.ts | 119 ++++++++--------- .../sandbox-local/tests/acl-grants.spec.ts | 121 ++++++++++++++++-- .../sandbox/sandbox-local/tests/local.spec.ts | 7 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 20 +-- .../sandbox/sandbox-windows-acl/README.md | 12 +- .../sandbox/sandbox-windows-acl/README.zh.md | 12 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 10 +- .../sandbox/sandbox-windows-acl/src/index.ts | 26 ++-- .../sandbox/sandbox-windows-acl/src/runner.ts | 26 ++-- .../sandbox-windows-acl/src/workspace-sid.ts | 39 ++---- .../sandbox-windows-acl/tests/grant.spec.ts | 17 ++- .../sandbox-windows-acl/tests/runner.spec.ts | 69 ++++++++++ .../tests/workspace-sid.spec.ts | 17 +++ packages/sandbox/sandbox/src/index.ts | 2 +- 30 files changed, 372 insertions(+), 189 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml index bcc2fe33cb..1368af8de4 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -9,11 +9,11 @@ en: 2a32db1f27371403 zh: 89cf04705ef66769 /agent-note-session-writable-directory-effects/decision: - en: af67969841bedb67 - zh: edabdfaa30bcadd1 + en: 504c8d2399c47110 + zh: 6c30739907a86f07 /agent-note-session-writable-directory-effects/alternatives-considered: en: dad77744dea17bde zh: bb3838f9f9099944 /agent-note-session-writable-directory-effects/consequences: - en: 7edcc1022b9f3ee5 - zh: 61163e25d5a90f16 + en: 008f7270b7d3aaaf + zh: 2b60ed4c4739ff75 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md index 1608b93010..bb3a47036b 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -16,6 +16,8 @@ Registration disposal affects subsequent operations, not already-resolved calls Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules. +Windows standing ACL grants use one deterministic SID per complete sorted, deduplicated canonical directory set. Each new execution resolves or creates that exact set's grant entry; changing the set never broadens the SID of a process already running. Reusing only the workspace identity would accumulate extra-directory authority across Sessions and after disposal. JSON encoding preserves directory boundaries in the hash input; singleton sets retain their existing SID. Standing ACEs remain reusable, while private temp grants keep their separate per-session/workspace identity and lifetime. + ## Alternatives considered **Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service. @@ -26,4 +28,4 @@ Missing, non-directory, and symlinked extra paths stop execution with `SandboxIn ## Consequences -Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocess confinement retains its existing unsupported-extra-roots error. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. +Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md index 3cc4f01c3f..9488ee695a 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -16,6 +16,8 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。 +Windows 常驻 ACL 授权为完整、排序去重后的规范目录集合使用一个确定性 SID。每次新执行查找或创建完全匹配该集合的授权项;集合变化不会扩大已运行进程的 SID 权限。如果仅复用工作区身份,额外目录权限就会在不同 Session 之间及注册清理后累积。JSON 编码在哈希输入中保留目录边界;单目录集合保留现有 SID。常驻 ACE 可继续复用,私有临时授权则保持独立的每会话/工作区身份与生命周期。 + ## 考虑过的替代方案 **共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。 @@ -26,4 +28,4 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 ## 影响 -同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程隔离保留现有的不支持额外根目录错误。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 +同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 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 cd33be628c..87816ade93 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 @@ -9,8 +9,8 @@ en: f62d87b7623a9904 zh: 48bbb7c35298f96c /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision: - en: 10e9cfb7972cff03 - zh: 8406ba64d72a1fcc + en: 7c8a053085200aee + zh: dd36659227430d93 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery: en: a5bcdf2a6133abfe zh: 477d4915d61945e3 @@ -30,8 +30,8 @@ en: e2c573d90c4ddeb9 zh: f0259213e1321722 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences: - en: dd43744417f207a5 - zh: d7b4fee1e2d87b60 + en: f14d0c8fb77d57ab + zh: 1842f13d91de8bd2 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing: en: b223cb2cf13193c9 zh: 61ddd0a0b99f8f56 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 6cda3f1970..acb525290b 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 original [historical sandbox decision](../../archived/feature/2026-07-06-san ## 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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). The write SID is derived deterministically from the complete canonical writable-directory set (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing ACEs are the reuse cache for that exact set, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## ACL diagnosis and recovery @@ -40,7 +40,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory set per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing 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 1f2848d31c..70a705f30f 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 ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。写入 SID 由完整的规范可写目录集合确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 是完全相同集合的复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## ACL 诊断与恢复 @@ -41,7 +41,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录集合只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2d91981949..dcd5781374 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2926,7 +2926,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b586ca6aa2..428de8d3aa 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2928,7 +2928,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index a3539c85c2..8b19c57010 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -64,7 +64,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while the workspace SID and standing grant remain per-workspace); absent + * while standing grants are keyed by the complete writable-directory set); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 78b8b63113..8c0df187c3 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -64,7 +64,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while the workspace SID and standing grant remain per-workspace); absent + * while standing grants are keyed by the complete writable-directory set); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 56f69b41d7..8f842f5e52 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: f0c44248c2ca6acc zh: 554e9f706671d222 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles: - en: 008967ee30fbbe46 - zh: 9e5c6a3e97851a69 + en: bd441e16c283095e + zh: eef437f45922e174 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects: en: 62adfc9229c656cc zh: 11b1534b6d61e5de @@ -54,8 +54,8 @@ en: e290ccc9b3c92c08 zh: 469589ac12cdadab /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work: - en: 38756b82fe0cd48c - zh: 441cc45ebe5b51fd + en: 71f863f5ee97d9ba + zh: cabc01953e6cb94d /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index dfe65511b6..5fd8d86984 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). -The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. +The Windows rung derives a deterministic write SID from each sorted, deduplicated set of canonical writable directories and reuses standing ACEs only for that exact set, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources. @@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. -- **Windows extra roots** — Windows ACL rejects workspace-write subprocesses with existing writable directories beyond the workspace with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations. +- **Windows temp separation** — writable roots cannot contain the ambient temp root or overlap the session's private temp directory. Those overlaps would expose private temp files through a shared directory capability. - **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 152110d5f6..b20edc2aaf 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 +Windows 档从排序、去重后的规范可写目录集合派生确定性写入 SID,并仅为完全相同的集合复用常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。 @@ -130,7 +130,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝授权工作区以外现存目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。 +- **Windows 临时目录隔离**——可写根目录不能包含系统临时根目录,也不能与会话的私有临时目录重叠。这类重叠会通过共享目录能力暴露私有临时文件。 - **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 5f46c01b51..b9eeb61946 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,19 +5,13 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung additionally owns the write grants: the write SID is - * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), while every live session receives a RANDOM private - * temp directory and its own derived capability (`tempWriteSid`). 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 both SIDs (their presence marks the seam-managed contract) and - * stops managing DACLs itself. The rung reports partial enforcement because - * NTFS hard links alias one file object across paths, reads stay unconfined, - * and a tree another AppContainer tool has ACL'd with a package SID is not - * readable by the Low-integrity child. + * The windows-acl rung owns standing grants keyed by the complete canonical + * writable-directory set. Each set has a deterministic write SID; executions + * with different sets never share that capability. Every live session/workspace + * pair has a separate random private temp directory and revocable temp SID. + * The runner receives both identities and does not manage their DACLs. + * Enforcement remains partial because hard links alias file objects, reads + * stay unconfined, and AppContainer ACLs can deny reads to the child. * @module @deepseek-ai/dsh-sandbox-local */ @@ -34,10 +28,10 @@ import { } from '@deepseek-ai/node-addon-system/landlock-run' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } 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, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -244,7 +238,7 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants - * ({@link AclWriteGrant}: the standing workspace-root grant per workspace + * ({@link AclWriteGrant}: standing grants per writable-directory set * and the revocable private-temp grant per live session/workspace pair, the * latter revoked on provider dispose); the one-time probes spawn nothing * else. @@ -265,14 +259,9 @@ 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 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 live session/workspace pair (revoked on provider - * dispose). - */ - private readonly workspaceGrants = new Map() + /** Standing grants keyed by the sorted canonical directory set; ACEs outlive the provider. */ + private readonly directoryGrants = new Map() + /** Revocable private temp grants per live session/workspace pair. */ private readonly tempCapabilities = new Map() constructor(ctx: Context, config: Config) { @@ -355,21 +344,15 @@ export class LocalSandboxProvider extends SandboxProvider { } /** - * The windows-acl runner argv for one policy. With a calling session (the - * policy's `sessionId`) under workspace-write, the grants are materialized - * once per provider lifetime — the standing workspace-root grant per - * workspace and a revocable, RANDOM private-temp capability per live - * session/workspace pair. The runner receives `--write-sid` plus - * `--temp-write-sid` and grants nothing itself. Agentless workspace-write - * calls pass the ambient temp ROOT and no SID flags: the runner creates and - * removes a random private child directory for that one invocation. + * Build the Windows runner invocation for the current writable-directory set. + * Session calls reuse standing directory grants and a private temp capability. + * Agentless calls let the runner materialize the set and own its private temp. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { - if (workspaceWritableRoots(policy).length > 1) { - throw new SandboxUnsupportedPolicyError('Windows ACL confinement does not support additional writable directories; remove the extra grants or use a supported host') - } + const roots = workspaceWritableRoots(policy) + const extraArgs = roots.slice(1).flatMap(root => ['--writable-root', root]) const sessionId = policy.sessionId if (sessionId === undefined || policy.mode === 'read-only') { return [ @@ -377,54 +360,54 @@ export class LocalSandboxProvider extends SandboxProvider { '--workspace', policy.workspaceRoot, '--temp', tmpdir(), '--mode', policy.mode, + ...extraArgs, ] } - const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot) + const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot, roots) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, '--temp', temp.dir, '--mode', policy.mode, - '--write-sid', workspaceWriteSid(policy.workspaceRoot), + ...extraArgs, + '--write-sid', workspaceWriteSid(policy.workspaceRoot, roots), '--temp-write-sid', temp.writeSid, ] } /** - * Materialize one workspace-write policy's ACEs once per provider - * lifetime. The workspace SID and standing root grant are shared by the - * workspace. The temp directory is random and carries a distinct SID, so - * another session on the same workspace cannot use the shared workspace - * SID to enter it. A fresh provider always chooses a new path; crash - * residue therefore cannot collide with or authorize a resumed session. - * Fail-closed: a half-materialized temp grant is revoked and its directory - * removed before the error propagates. + * Resolve or materialize the exact writable-directory set's standing ACEs. + * Private temp capabilities remain scoped to the session/workspace pair; + * changing directory sets does not revoke grants held by running processes. + * Failed temp materialization revokes its grant and removes its directory. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. + * @param roots - the complete canonical writable-directory set for this execution. * @returns the pair's private temp directory and write capability. */ - private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { - assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) - const writeSid = workspaceWriteSid(workspaceRoot) - if (!this.workspaceGrants.has(workspaceRoot)) { - const grant = AclWriteGrant.create(writeSid) - try { - 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 - // 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(workspaceRoot, grant) - } + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, roots: readonly string[]): AclTempCapability { + for (const root of roots) assertTempRootOutsideWorkspace(root, tmpdir()) const key = JSON.stringify([String(sessionId), workspaceRoot]) const existing = this.tempCapabilities.get(key) + if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir) + const writeSid = workspaceWriteSid(workspaceRoot, roots) + const grantKey = JSON.stringify([...roots].sort()) + const cachedGrant = this.directoryGrants.get(grantKey) + const directoryGrant = cachedGrant ?? AclWriteGrant.create(writeSid) + try { + for (const root of roots) directoryGrant.add(root, true) + } catch (error) { + if (cachedGrant !== undefined) throw error + // Standing ACEs remain reusable; only the failed entry's allocations + // are released. A later execution retries the complete directory set. + try { + directoryGrant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed') + } + throw error + } + this.directoryGrants.set(grantKey, directoryGrant) if (existing !== undefined) return existing const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) const tempSid = tempWriteSid(tempDir) @@ -466,9 +449,9 @@ export class LocalSandboxProvider extends SandboxProvider { * OS temp hygiene (or manual removal) eventually reclaims it. */ private revokeAclGrants(): void { - if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return + if (this.directoryGrants.size === 0 && this.tempCapabilities.size === 0) return const failures: unknown[] = [] - for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { + for (const grant of [...this.directoryGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { try { grant.dispose() } catch (error) { @@ -482,7 +465,7 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } - this.workspaceGrants.clear() + this.directoryGrants.clear() this.tempCapabilities.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 646377e85c..63020397cf 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -5,7 +5,7 @@ * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,18 +13,21 @@ import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' /** 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, + addFailurePath: undefined as string | undefined, /** Restrict an add failure to standing (workspace) or revocable (temp). */ addFailureStanding: undefined as boolean | undefined, createTempFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) -vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => { + const actual = await importOriginal() class MockAclWriteGrant { readonly writeSid: string readonly added: Array<{ path: string; standing: boolean }> = [] @@ -40,6 +43,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { add(path: string, standing = false): void { this.added.push({ path, standing }) if (mockState.addFailure !== undefined + && (mockState.addFailurePath === undefined || mockState.addFailurePath === path) && (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) { throw mockState.addFailure } @@ -50,6 +54,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { } } return { + ...actual, registerAclDiagnosisSkill: vi.fn(), AclWriteGrant: MockAclWriteGrant, assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { @@ -59,13 +64,10 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) } }, - workspaceWriteSid: () => 'S-1-4-42-42', tempWriteSid: (path: string) => `TEMP:${path}`, } }) -const WORKSPACE_SID = 'S-1-4-42-42' - async function setup() { const ctx = new Context() const fiber = await ctx.plugin(LocalSandboxProvider, {}) @@ -89,6 +91,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined + mockState.addFailurePath = undefined mockState.addFailureStanding = undefined mockState.createTempFailure = undefined mockState.disposeFailure = undefined @@ -129,19 +132,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(tempDir).toBeDefined() expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) expect(tempSid).toBe(`TEMP:${tempDir}`) - expect(tempSid).not.toBe(WORKSPACE_SID) + expect(tempSid).not.toBe(workspaceWriteSid(ws)) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', WORKSPACE_SID, + '--write-sid', workspaceWriteSid(ws), '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toEqual([ - expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: workspaceWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }), expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), ]) expect(existsSync(tempDir ?? '')).toBe(true) @@ -157,6 +160,104 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) + it('selects exact directory-set grants while retaining session-private temp capabilities', async () => { + const { sandbox, fiber } = await setup() + try { + const ws = workspaceRoot() + const memory = workspaceRoot() + const cache = workspaceRoot() + scratch.push(ws, memory, cache) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') } + const base = await sandbox.confine(['true'], policy) + const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] }) + const expandedSid = workspaceWriteSid(ws, [memory, cache]) + expect(flag(expanded.argv, '--write-sid')).toBe(expandedSid) + expect(expandedSid).not.toBe(flag(base.argv, '--write-sid')) + expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp')) + expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid')) + expect(mockState.grants.filter(grant => grant.writeSid === expandedSid)).toEqual([ + expect.objectContaining({ added: [ws, memory, cache].map(path => ({ path, standing: true })), disposed: false }), + ]) + const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] }) + expect(flag(reordered.argv, '--write-sid')).toBe(expandedSid) + expect(mockState.grants).toHaveLength(3) + expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv) + expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) + + const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') }) + expect(flag(sibling.argv, '--write-sid')).toBe(flag(base.argv, '--write-sid')) + expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid')) + expect(mockState.grants).toHaveLength(4) + } finally { await fiber.dispose(); cleanup() } + }) + + it('reapplies a cached set grant when an extra directory is recreated', async () => { + const { sandbox, fiber } = await setup() + try { + const ws = workspaceRoot() + const extra = workspaceRoot() + scratch.push(ws, extra) + const policy: SandboxPolicy = { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra], + } + const first = await sandbox.confine(['true'], policy) + const directoryGrant = mockState.grants[0]! + rmSync(extra, { recursive: true }) + mkdirSync(extra) + const second = await sandbox.confine(['true'], policy) + expect(second.argv).toEqual(first.argv) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toBe(directoryGrant) + expect(directoryGrant.added).toEqual([ws, extra, ws, extra].map(path => ({ path, standing: true }))) + } finally { await fiber.dispose(); cleanup() } + }) + + it('retries every directory after a partial set grant fails', async () => { + const { sandbox, fiber } = await setup() + try { + const ws = workspaceRoot() + const extra = workspaceRoot() + scratch.push(ws, extra) + const policy: SandboxPolicy = { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('set-retry'), additionalWritableRoots: [extra], + } + mockState.addFailure = new Error('extra grant failed') + mockState.addFailurePath = extra + await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ + added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: true, + }) + mockState.addFailure = undefined + await sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[1]).toMatchObject({ + added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: false, + }) + mockState.addFailure = new Error('cached grant refresh failed') + await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed') + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[1]!.disposed).toBe(false) + mockState.addFailure = undefined + await sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(3) + } finally { await fiber.dispose(); cleanup() } + }) + + it('refuses a changed set overlapping its private temp before adding standing grants', async () => { + const { sandbox, fiber } = await setup() + try { + const ws = workspaceRoot() + scratch.push(ws) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-overlap') } + const confined = await sandbox.confine(['true'], policy) + const temp = realpathSync.native(flag(confined.argv, '--temp')!) + await expect(sandbox.confine(['true'], { ...policy, additionalWritableRoots: [temp] })) + .rejects.toThrow('private temp directory must be disjoint from writable directories') + expect(mockState.grants).toHaveLength(2) + } finally { await fiber.dispose(); cleanup() } + }) + it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => { try { const { sandbox, fiber } = await setup() @@ -176,7 +277,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(0) const upgraded = await sandbox.confine(['true'], workspaceWrite) - expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(workspaceWriteSid(ws)) expect(mockState.grants).toHaveLength(2) await sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) @@ -252,7 +353,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.disposeFailure = new Error('workspace cleanup exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), - })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u) + })).rejects.toThrow(/directory grant failed and its cleanup also failed/u) expect(mockState.grants).toHaveLength(2) } finally { cleanup() diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 36eb46c9c1..928578851e 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -430,7 +430,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { }) describe('the windows-acl probe (runner invocation contract)', () => { - it('refuses unsupported extra grants before a Windows runner or ACL mutation', async () => { + it('passes existing extra directories to the Windows runner and skips non-directories', async () => { const { ctx, sandbox } = await setup({}, { chain: ['windows-acl'], probeWindowsAcl: () => true, @@ -442,8 +442,9 @@ describe('the windows-acl probe (runner invocation contract)', () => { writeFileSync(file, 'file') try { await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory') - await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })) - .rejects.toThrow('Windows ACL confinement does not support additional writable directories') + const confined = await sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }) + expect(confined.argv).toContain('--writable-root') + expect(confined.argv[confined.argv.indexOf('--writable-root') + 1]).toBe(extra) await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined() } finally { await ctx.fiber.dispose() diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 3638e6ac9f..61c94dc100 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -24,8 +24,8 @@ en: c7e924a923bfac72 zh: fdaf930a5800ffa6 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: - en: 169b82d5392ec91a - zh: e0ef9428cb794f9c + en: 28587491ad2ac66a + zh: c5d4dc441e3f0459 /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 431c140934..1a4e672fdc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply. -Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). +Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. ### Switching a session's mode diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index f2d3a88632..377b6db5bd 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。 -注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 +注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。 ### 切换会话模式 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 94a3e37675..e778ee1934 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -9,8 +9,8 @@ en: e7cdbd2652fe80ab zh: 04e1d187313a0bd5 /deepseek-ai-dsh-sandbox-windows-acl/summary: - en: 777921135c23ee55 - zh: 7c726cc63995d443 + en: dac35dbc120ce3ec + zh: b46a5c0d31af6f1f /deepseek-ai-dsh-sandbox-windows-acl/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -24,8 +24,8 @@ en: 16596607bbccc2a3 zh: 30bc370a5281bf0d /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you: - en: 289e65bddf20dd79 - zh: 7ff72ed64c4b4d1c + en: 0ae33482d28f637b + zh: a2b87b18737a6d07 /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: en: 7fc0707debb1e67c zh: e3b5529bf82f868e @@ -33,14 +33,14 @@ en: c2cde86c85c2c619 zh: a29a114e591f9d3f /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism: - en: a37ea37f2d527693 - zh: 749b5a263d0e1534 + en: 97ef643f608ae7d8 + zh: bf4eb7941f4573f6 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists: - en: 9c8d9af84f9abd99 - zh: 1c82b355aa5f210f + en: 1a4ed5c06a5be74d + zh: 053cb81bd76260ed /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner: - en: 0847de0e53e50061 - zh: 90fa3dcaf080c3be + en: de2dce4b18f77dad + zh: bbe7eef92b441364 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries: en: ad02570eae90037f zh: fd78c2318de18dda diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 732ca20a35..9344325c43 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. +On Windows, this package confines child-process writes and deletes to the workspace, explicit extra directories, and a private temporary directory: `workspace-write` grants these directories, `read-only` grants none. Mount `dsh-sandbox-local` for confined bash and PowerShell commands, or use `AclSandbox`; Win32 failures never cause unrestricted spawning. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -67,7 +67,7 @@ The workspace security descriptor edits are granted standing — `dispose()` lea ### What confinement gives you -Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. +Under `workspace-write`, the child may write into the workspace, its explicit extra roots, and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. @@ -95,11 +95,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `workspaceWriteSid(workspaceRoot, additionalWritableRoots)` hashes the sorted, deduplicated canonical directory set with unambiguous JSON encoding; a workspace-only set preserves its existing SID. Each execution resolves or materializes standing ACEs for that exact set. Adding or removing a directory selects a different SID for new processes, while running processes keep their launch token. Equal sets reuse their standing security descriptor edits through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, directory-set SID, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a 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. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -110,10 +110,10 @@ Authenticated Users is absent from both lists — the WMI namespace security che The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's runner-failure rules match that signature. ```sh -node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- +node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] -- ``` -The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant. +The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry for the exact directory set, rechecking each root's ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies the directory-set SID against all selected roots and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index ad9de70659..2f11123c00 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 +在 Windows 上,本包将子进程的写入与删除限制在工作区、显式额外目录和私有临时目录内:`workspace-write` 授予这些目录,`read-only` 均不授予。挂载 `dsh-sandbox-local` 可约束 bash 与 PowerShell 命令,也可使用 `AclSandbox`;Win32 失败绝不会导致不受限制的进程启动。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -67,7 +67,7 @@ rmSync(tempDir, { recursive: true, force: true }) ### 隔离给你带来什么 -在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 +在 `workspace-write` 下,子进程可以写入工作区、显式额外根目录及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 @@ -97,11 +97,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`workspaceWriteSid(workspaceRoot, additionalWritableRoots)` 对排序、去重后的规范目录集合使用无歧义的 JSON 编码求哈希;仅含工作区的集合保留现有 SID。每次执行查找或物化完全匹配该集合的常驻 ACE。增加或移除目录会为新进程选择不同的 SID,运行中的进程保留启动令牌。相同集合通过精确 ACE/精确拒绝/精确标签跳过,复用常驻安全描述符改动。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 -`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 +`workspace-write`(登录 SID、Everyone、目录集合 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。 @@ -112,10 +112,10 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全 面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 runner 失败规则匹配该签名。 ```sh -node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- +node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] -- ``` -seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。 +seam 通过重复的 `--writable-root` 参数传入额外根目录,并为完全相同的目录集合复用授权项,每次执行前重新检查各根目录的 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 根据全部选定根目录验证目录集合 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7f7bcc4c57..2316b0f6ed 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,7 +1,7 @@ /** * Server-side write-grant materialization. The sandbox seam holds one - * standing workspace grant per workspace and one revocable temp grant per - * live session/workspace pair. Workspace identities survive by deterministic + * standing grant per writable-directory set and one revocable temp grant per + * live session/workspace pair. Directory-set identities survive by deterministic * derivation and their standing ACE; temp identities derive from random * private paths and are deliberately new after a restart. * @@ -91,7 +91,8 @@ export class AclWriteGrant { * Grant the write ACE, the ambient-delete deny, and the Low mandatory label * on one directory (idempotent: an already-standing exact ACE, deny, and * label skip the eager full-tree re-propagation — see {@link grantWrite}) - * and record the path for {@link dispose} unless it is standing. The path is + * and record the path once for {@link dispose} unless it is standing. Repeated + * calls recheck the ACE so a recreated directory receives its grant. 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 @@ -103,7 +104,8 @@ export class AclWriteGrant { * dispose — the temp-directory lifecycle). */ add(path: string, standing = false): void { - ;(standing ? this.standingPaths : this.revocablePaths).push(path) + const paths = standing ? this.standingPaths : this.revocablePaths + if (!paths.includes(path)) paths.push(path) grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr) } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 5de5d47b72..9413dc60c2 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -13,13 +13,11 @@ * for the complete boundary). The intersection covers only the object's own * access check, so the token is also lowered to Low integrity and every * granted directory carries a Low no-write-up label and the ambient-delete - * deny the `acl` module documents. 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. Each private temp directory instead receives its own SID, - * so sibling sessions sharing a workspace cannot enter one another's temp + * deny the `acl` module documents. The write SID identifies the complete + * canonical writable-directory set ({@link workspaceWriteSid}). Standing + * ACEs are reusable across calls and sessions with that exact set. Each + * private temp directory instead receives its own SID, so sibling sessions + * cannot use a shared directory-set capability to enter one another's temp * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * @@ -56,7 +54,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' -export { assertTempRootOutsideWorkspace } from './path-boundary.ts' +export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ @@ -72,9 +70,9 @@ export interface AclSandboxOptions { /** * 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. + * derive it from the complete canonical writable-directory set via + * {@link workspaceWriteSid}. Standing ACEs outlive sandbox instances and + * are reusable by later executions carrying that exact set identity. */ writeSid?: string /** @@ -163,7 +161,7 @@ function freeSidBestEffort( export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The workspace SID string whose ACEs form the workspace allowlist. */ + /** The directory-set SID whose ACEs form the writable allowlist. */ readonly writeSid: string | undefined /** The private temp directory's write SID (workspace-write with temp only). */ readonly tempWriteSid: string | undefined @@ -194,7 +192,7 @@ export class AclSandbox { this.writeSid = options.writeSid this.tempWriteSid = options.tempWriteSid 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()') + throw new Error('AclSandbox workspace-write requires a write SID — derive it from the writable directories via workspaceWriteSid()') } if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') @@ -256,7 +254,7 @@ export class AclSandbox { // 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 + // directory-set 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 before the private directory is // deleted; the ambient temp root is never granted). diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 8c673780a2..155c5a5c82 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,7 +1,7 @@ /** * 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 workspace write-SID allowlist, spawns the + * WRITE_RESTRICTED token with the directory-set 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). @@ -9,12 +9,12 @@ * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , - * '--mode', , + * '--mode', , ['--writable-root', ]..., * ['--write-sid', , * '--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: the workspace and temp directories carry distinct + * - workspace-write: the writable-directory set and private temp carry distinct * capability-SID Write grants; other ACL-addressable writes are denied * except for the documented Everyone and hard-link boundaries. * - read-only: no capability-SID grants; the restricting list carries no @@ -27,7 +27,7 @@ * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the * CALLER has already materialized distinct workspace and private-temp ACEs * and owns their revocation, so the runner neither grants nor revokes - * (`manageDacls: false`). Both values are checked against their owning paths. + * (`manageDacls: false`). Both values are checked against their owning directory set or private path. * Without the pair (standalone/agentless use), workspace-write treats * `--temp` as a ROOT, creates a random private child directory, derives its * own temp SID, and removes that directory after the child exits. In both @@ -67,6 +67,7 @@ interface ParsedArgs { workspace: string temp: string mode: 'read-only' | 'workspace-write' + writableRoots: string[] writeSid: string | undefined tempWriteSid: string | undefined command: string @@ -74,6 +75,7 @@ interface ParsedArgs { } function parseArgs(raw: string[]): ParsedArgs { + const writableRoots: string[] = [] let workspace: string | undefined let temp: string | undefined let mode: string | undefined @@ -91,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs { if (value === undefined) fail(`missing value after ${token}`) switch (token) { case '--workspace': workspace = value; break + case '--writable-root': writableRoots.push(value); break case '--temp': temp = value; break case '--mode': mode = value; break case '--write-sid': writeSid = value; break @@ -104,7 +107,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, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writableRoots, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -119,8 +122,13 @@ async function main(): Promise { // a bogus root must fail loudly at the runner boundary, never mid-child. requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) + for (const root of parsed.writableRoots) requireDirectory('--writable-root', root) + const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots])] const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined + if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) { + fail('read-only does not accept --writable-root') + } if (parsed.mode === 'read-only' && seamManaged) { fail('read-only does not accept --write-sid or --temp-write-sid') } @@ -128,7 +136,7 @@ async function main(): Promise { fail('workspace-write requires --write-sid and --temp-write-sid together') } if (parsed.mode === 'workspace-write') { - assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp) + for (const root of writableDirs) assertTempRootOutsideWorkspace(root, parsed.temp) } const api = await win32() @@ -147,9 +155,9 @@ async function main(): Promise { let writeSid: string | undefined let privateTempSid: string | undefined if (parsed.mode === 'workspace-write') { - writeSid = workspaceWriteSid(parsed.workspace) + writeSid = workspaceWriteSid(parsed.workspace, parsed.writableRoots) if (seamManaged) { - if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace') + if (parsed.writeSid !== writeSid) fail('--write-sid does not match writable directories') privateTempDir = parsed.temp privateTempSid = tempWriteSid(privateTempDir) if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') @@ -160,7 +168,7 @@ async function main(): Promise { } } sandbox = new AclSandbox({ - writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [], tempDir: privateTempDir, mode: parsed.mode, ...writeSid === undefined ? {} : { writeSid }, diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index db313ce092..58c9c0dd4e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -1,39 +1,24 @@ /** - * 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. Temporary directories use a separate, - * per-directory identity from {@link tempWriteSid}; sharing the workspace - * identity with temp would let sibling sessions write one another's temp - * trees. - * - * 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. + * Deterministic write identities for canonical writable-directory sets and + * separate identities for private temp directories. Standing directory ACEs + * are reusable only by executions carrying the same complete set identity. * @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 workspace-capability shape the token and ACE layers carry). + * Derive the write SID for a workspace and its additional writable roots. + * Order and duplicate entries do not affect the identity. A workspace-only + * set retains its existing SID; larger sets use unambiguous JSON encoding. * @param workspaceRoot - the canonical workspace path. - * @returns the SDDL string form. + * @param additionalWritableRoots - canonical extra directories; defaults to none. + * @returns the deterministic SDDL string for the complete directory set. */ -export function workspaceWriteSid(workspaceRoot: string): string { - const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest() +export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots: readonly string[] = []): string { + const roots = [...new Set([workspaceRoot, ...additionalWritableRoots])].sort() + const identity = roots.length === 1 ? workspaceRoot : JSON.stringify(roots) + const digest = createHash('sha256').update(identity, '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/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts index 43810249ab..acaa4a010d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -8,7 +8,7 @@ */ import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -53,6 +53,7 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { // grant stays exactly one ACE (the reuse across sessions/restarts). grant.add(dir) grant.add(standingDir, true) + expect(grant.paths).toEqual([standingDir, dir]) expect(icaclsText(dir)).toContain('S-1-4-9000-77') expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') grant.dispose() @@ -60,6 +61,20 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') }) + it('restores a standing ACE on a recreated directory without duplicating its tracked path', () => { + const dir = scratch() + const grant = AclWriteGrant.create('S-1-4-9000-80') + try { + grant.add(dir, true) + rmSync(dir, { recursive: true }) + mkdirSync(dir) + expect(icaclsText(dir)).not.toContain(grant.writeSid) + grant.add(dir, true) + expect(icaclsText(dir)).toContain(grant.writeSid) + expect(grant.paths).toEqual([dir]) + } finally { grant.dispose() } + }) + it('two grants with different SIDs coexist and revoke independently', () => { const dir = scratch() const grantA = AclWriteGrant.create('S-1-4-9000-78') diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index ebac7ffa0e..413afe2f69 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -107,6 +107,62 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) + it('new processes use the exact writable set despite standing grants from a broader set', () => { + const extra = mkdtempSync(join(scratchRoot, 'extra-set-')) + const workspaceFile = join(writableDir, 'set-workspace.txt') + const extraFile = join(extra, 'set-extra.txt') + const probe = "const fs = require('node:fs'); for (const path of process.argv.slice(1)) { try { fs.writeFileSync(path, 'written'); console.log('ALLOW ' + path) } catch { console.log('DENY ' + path) } }" + const expanded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--writable-root', extra, + '--', process.execPath, '-e', probe, workspaceFile, extraFile, + ]) + expect(expanded.error).toBeUndefined() + expect(expanded.signal).toBeNull() + expect(expanded.status, expanded.stderr).toBe(0) + expect(readFileSync(extraFile, 'utf8')).toBe('written') + writeFileSync(extraFile, 'retained') + + const narrowed = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', probe, workspaceFile, extraFile, + ]) + expect(narrowed.error).toBeUndefined() + expect(narrowed.signal).toBeNull() + expect(narrowed.status, narrowed.stderr).toBe(0) + expect(narrowed.stdout).toContain(`ALLOW ${workspaceFile}`) + expect(narrowed.stdout).toContain(`DENY ${extraFile}`) + expect(readFileSync(extraFile, 'utf8')).toBe('retained') + }, 90_000) + + it('accepts a provider-materialized SID covering the workspace and additional directories', () => { + const extra = mkdtempSync(join(scratchRoot, 'managed-extra-')) + const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-')) + const writeSid = workspaceWriteSid(writableDir, [extra]) + const privateTempSid = tempWriteSid(privateTemp) + const directoryGrant = AclWriteGrant.create(writeSid) + const tempGrant = AclWriteGrant.create(privateTempSid) + try { + directoryGrant.add(writableDir, true) + directoryGrant.add(extra, true) + tempGrant.add(privateTemp) + const target = join(extra, 'managed-write.txt') + const result = runRunner([ + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', + '--writable-root', extra, '--write-sid', writeSid, '--temp-write-sid', privateTempSid, + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target, + ]) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(target, 'utf8')).toBe('managed') + } finally { + directoryGrant.dispose() + tempGrant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) + } + }, 90_000) + it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM availability follows the caller token', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", @@ -642,11 +698,24 @@ TryOpen 'DIRECTORY' '${child}' expect(result.stderr).toContain('windows-acl-run: ') }, 15_000) + it('read-only rejects writable-directory flags before starting the command', () => { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', + '--writable-root', worldWritableDir, + '--', process.execPath, '-e', 'process.exit(99)', + ]) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status, result.stderr).toBe(127) + expect(result.stderr).toContain('read-only does not accept --writable-root') + }, 90_000) + it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { const writeSid = workspaceWriteSid(writableDir) const tempSid = tempWriteSid(isolatedTemp) const cases = [ ['--write-sid', writeSid], + ['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid], ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], ] diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 7fdef07d53..82b9d055e0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -19,6 +19,23 @@ describe('workspaceWriteSid', () => { expect(first).toMatch(/^S-1-4-\d+-\d+$/u) }) + it('uses the same identity for reordered and duplicated writable-directory sets', () => { + const workspace = 'C:\\repo' + const memory = 'C:\\memory' + const cache = 'C:\\cache' + const sid = workspaceWriteSid(workspace, [memory, cache]) + expect(workspaceWriteSid(workspace, [cache, workspace, memory, cache])).toBe(sid) + expect(workspaceWriteSid(memory, [workspace, cache])).toBe(sid) + expect(workspaceWriteSid(workspace, [workspace])).toBe(workspaceWriteSid(workspace)) + expect(sid).not.toBe(workspaceWriteSid(workspace)) + expect(sid).not.toBe(workspaceWriteSid(workspace, [memory])) + }) + + it('keeps path boundaries distinct when their concatenated text matches', () => { + expect(workspaceWriteSid('C:\\a', ['C:\\bC:\\c'])) + .not.toBe(workspaceWriteSid('C:\\aC:\\b', ['C:\\c'])) + }) + it('derives distinct identities for distinct workspaces', () => { expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) }) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 0fa3183987..01d14f51ef 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -53,7 +53,7 @@ export interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while the workspace SID and standing grant remain per-workspace); absent + * while standing grants are keyed by the complete writable-directory set); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId From 0120f0c01e50d75565397d860f68f1638d466ef0 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 28 Oct 2026 18:27:51 +0800 Subject: [PATCH 11/16] refactor(sandbox): compose Windows write permissions per directory --- ...ssion-writable-directory-effects.i18n.yaml | 4 +- ...0-26-session-writable-directory-effects.md | 2 +- ...6-session-writable-directory-effects.zh.md | 2 +- ...ndows-directory-write-identities.i18n.yaml | 19 +++ ...0-28-windows-directory-write-identities.md | 25 ++++ ...8-windows-directory-write-identities.zh.md | 25 ++++ ...ows-acl-restricted-token-sandbox.i18n.yaml | 8 +- ...08-windows-acl-restricted-token-sandbox.md | 4 +- ...windows-acl-restricted-token-sandbox.zh.md | 4 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../windows-directory-sids/guide.i18n.yaml | 16 +++ .../windows-directory-sids/guide.md | 19 +++ .../windows-directory-sids/guide.zh.md | 19 +++ .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 44 +++---- .../sandbox-local/tests/acl-grants.spec.ts | 78 ++++++------ .../sandbox-windows-acl/README.i18n.yaml | 12 +- .../sandbox/sandbox-windows-acl/README.md | 11 +- .../sandbox/sandbox-windows-acl/README.zh.md | 11 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 4 +- .../sandbox/sandbox-windows-acl/src/index.ts | 83 ++++++------- .../sandbox/sandbox-windows-acl/src/runner.ts | 41 +++---- .../sandbox/sandbox-windows-acl/src/token.ts | 4 +- .../sandbox-windows-acl/src/workspace-sid.ts | 23 ++-- .../sandbox-windows-acl/tests/acl.spec.ts | 56 ++++++--- .../tests/index-failure-paths.spec.ts | 115 ++++++++++++++---- .../sandbox-windows-acl/tests/probe.spec.ts | 7 +- .../sandbox-windows-acl/tests/runner.spec.ts | 37 +++--- .../tests/workspace-sid.spec.ts | 46 ++----- packages/sandbox/sandbox/src/index.ts | 2 +- 33 files changed, 454 insertions(+), 279 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml index 1368af8de4..ea30576b82 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -9,8 +9,8 @@ en: 2a32db1f27371403 zh: 89cf04705ef66769 /agent-note-session-writable-directory-effects/decision: - en: 504c8d2399c47110 - zh: 6c30739907a86f07 + en: 1ce0f8414a0f9db1 + zh: 0d2da6cf8ac2adbb /agent-note-session-writable-directory-effects/alternatives-considered: en: dad77744dea17bde zh: bb3838f9f9099944 diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md index bb3a47036b..edbd205025 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -16,7 +16,7 @@ Registration disposal affects subsequent operations, not already-resolved calls Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules. -Windows standing ACL grants use one deterministic SID per complete sorted, deduplicated canonical directory set. Each new execution resolves or creates that exact set's grant entry; changing the set never broadens the SID of a process already running. Reusing only the workspace identity would accumulate extra-directory authority across Sessions and after disposal. JSON encoding preserves directory boundaries in the hash input; singleton sets retain their existing SID. Standing ACEs remain reusable, while private temp grants keep their separate per-session/workspace identity and lifetime. +Windows standing ACL grants follow the [per-directory identity decision](2026-10-28-windows-directory-write-identities.md): each new execution carries only its currently permitted directory SIDs, while private temp capabilities remain separate. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md index 9488ee695a..67d5c9bbf5 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -16,7 +16,7 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。 -Windows 常驻 ACL 授权为完整、排序去重后的规范目录集合使用一个确定性 SID。每次新执行查找或创建完全匹配该集合的授权项;集合变化不会扩大已运行进程的 SID 权限。如果仅复用工作区身份,额外目录权限就会在不同 Session 之间及注册清理后累积。JSON 编码在哈希输入中保留目录边界;单目录集合保留现有 SID。常驻 ACE 可继续复用,私有临时授权则保持独立的每会话/工作区身份与生命周期。 +Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-directory-write-identities.zh.md):每次新执行仅携带当前允许的目录 SID,私有临时能力继续独立。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml new file mode 100644 index 0000000000..583e918ef5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-28-windows-directory-write-identities.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md +/agent-note-windows-per-directory-write-identities: + en: df2c97b00cabb275 + zh: e09a61baf823ff5e +/agent-note-windows-per-directory-write-identities/problem: + en: ce436e11d96c3c96 + zh: dcda6f85413a5be6 +/agent-note-windows-per-directory-write-identities/decision: + en: edbc0f1001721065 + zh: a91b9b2c1e19e6d2 +/agent-note-windows-per-directory-write-identities/alternatives-considered: + en: d87e2cf9196d66e7 + zh: 04261fa61efe407b +/agent-note-windows-per-directory-write-identities/consequences: + en: b9c9e40cf1e09f4e + zh: 6e69f792bbdd7726 diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md new file mode 100644 index 0000000000..7076514a10 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md @@ -0,0 +1,25 @@ +# Agent Note: Windows per-directory write identities + +Status: implemented + +English | [中文](2026-10-28-windows-directory-write-identities.zh.md) + +## Problem + +[Session-scoped writable roots](2026-10-26-session-writable-directory-effects.md) let independent plugins add directories to an execution's policy. A SID derived from the complete directory set requires additional standing ACEs and tree propagation on unchanged directories whenever that combination changes. + +## Decision + +Each canonical writable directory has one deterministic SID, granted only to that directory. Each new execution carries the SIDs for all its permitted directories. The provider caches grants by directory and rechecks their ACEs before execution so directory recreation does not invalidate the cache. Shared directories reuse the same standing grant across Sessions and permission combinations; a Session without the directory's SID receives no authority from that grant. + +`AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots. + +## Alternatives considered + +**One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse. + +**One workspace SID extended to extra directories.** This accumulates authority for processes that already hold the workspace SID and for other Sessions using that workspace. Adding a directory must select another existing directory capability, not expand a shared capability's meaning. + +## Consequences + +Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The underlying restricted-token and hard-link limitations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md new file mode 100644 index 0000000000..ecaab18b84 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 逐目录写入身份 + +Status: implemented + +[English](2026-10-28-windows-directory-write-identities.md) | 中文 + +## 问题 + +[会话级可写根目录](2026-10-26-session-writable-directory-effects.zh.md)允许独立插件向执行策略添加目录。如果 SID 由完整目录集合派生,每次组合变化都需要向未变化的目录添加常驻 ACE,并重新传播整棵目录树。 + +## 决策 + +每个规范可写目录拥有一个确定性 SID,仅在该目录上授予它。每次新执行携带全部允许目录的 SID。提供方按目录缓存授权,并在执行前重新检查 ACE,避免目录重建使缓存失效。共享目录在不同 Session 和权限组合之间复用同一个常驻授权;未携带该目录 SID 的 Session 无法从该授权获得权限。 + +`AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。 + +## 考虑过的替代方案 + +**每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。 + +**将工作区 SID 扩展到额外目录。** 这会为已经持有工作区 SID 的进程及使用该工作区的其他 Session 累积权限。添加目录必须选择另一个已有目录能力,而不能扩大共享能力的含义。 + +## 影响 + +已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。底层受限令牌及硬链接限制保持不变。 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 87816ade93..8217e734a6 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 @@ -9,8 +9,8 @@ en: f62d87b7623a9904 zh: 48bbb7c35298f96c /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision: - en: 7c8a053085200aee - zh: dd36659227430d93 + en: 0cd47aef022d86ad + zh: 1f8c5006db51c97b /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery: en: a5bcdf2a6133abfe zh: 477d4915d61945e3 @@ -30,8 +30,8 @@ en: e2c573d90c4ddeb9 zh: f0259213e1321722 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences: - en: f14d0c8fb77d57ab - zh: 1842f13d91de8bd2 + en: 5b9657f4247b6a79 + zh: e024d34bbab082a7 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing: en: b223cb2cf13193c9 zh: 61ddd0a0b99f8f56 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 acb525290b..412933dbf1 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 original [historical sandbox decision](../../archived/feature/2026-07-06-san ## 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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). The write SID is derived deterministically from the complete canonical writable-directory set (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing ACEs are the reuse cache for that exact set, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## ACL diagnosis and recovery @@ -40,7 +40,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory set per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing 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 70a705f30f..daea838039 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 ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。写入 SID 由完整的规范可写目录集合确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 是完全相同集合的复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## ACL 诊断与恢复 @@ -41,7 +41,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录集合只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 8b19c57010..f52a93e51a 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -64,7 +64,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by the complete writable-directory set); absent + * while standing grants are keyed by individual writable directories); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 8c0df187c3..10fd3ad925 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -64,7 +64,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by the complete writable-directory set); absent + * while standing grants are keyed by individual writable directories); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml new file mode 100644 index 0000000000..15f12a8dbc --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md +/: + en: dbd8a58fb86d9bd4 + zh: 1e8f6661b7c9122d +/windows-acl-directory-identities: + en: fd8ac37f696dac64 + zh: b5531e3c276b0c88 +/windows-acl-directory-identities/change: + en: ae9055a597ae4e32 + zh: 9fe43036ec0372ac +/windows-acl-directory-identities/migration: + en: 601f6832ab87b74f + zh: 568afc8d964c4da7 diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md new file mode 100644 index 0000000000..fb0ab47ad2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Windows ACL callers use derived per-directory SIDs instead of supplying one shared write SID." +--- + +# Windows ACL directory identities + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. Users of the standard `dsh` profiles need no configuration changes. + +## Migration + +1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. +2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories. +3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag. +4. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md new file mode 100644 index 0000000000..c9bd0b60c4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供一个共享的写入 SID。" +--- + +# Windows ACL 目录身份 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。标准 `dsh` profile 的用户无需修改配置。 + +## 迁移 + +1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。 +2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。 +3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。 +4. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 8f842f5e52..69844719dd 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: f0c44248c2ca6acc zh: 554e9f706671d222 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles: - en: bd441e16c283095e - zh: eef437f45922e174 + en: c167bc9f232d1103 + zh: a2172af886eff752 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects: en: 62adfc9229c656cc zh: 11b1534b6d61e5de diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 5fd8d86984..481829b0d2 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). -The Windows rung derives a deterministic write SID from each sorted, deduplicated set of canonical writable directories and reuses standing ACEs only for that exact set, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. +The Windows rung derives one deterministic write SID per canonical writable directory and reuses its standing ACE across permission combinations, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index b20edc2aaf..65d301dfd0 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档从排序、去重后的规范可写目录集合派生确定性写入 SID,并仅为完全相同的集合复用常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 +Windows 档为每个规范可写目录派生一个确定性写入 SID,并在不同权限组合之间复用其常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index b9eeb61946..c24e175548 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,9 +5,9 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung owns standing grants keyed by the complete canonical - * writable-directory set. Each set has a deterministic write SID; executions - * with different sets never share that capability. Every live session/workspace + * The windows-acl rung owns standing grants keyed by canonical directory. + * Each directory has a deterministic write SID, and an execution carries only + * the directory SIDs its current policy permits. Every live session/workspace * pair has a separate random private temp directory and revocable temp SID. * The runner receives both identities and does not manage their DACLs. * Enforcement remains partial because hard links alias file objects, reads @@ -31,7 +31,7 @@ import z from '@deepseek-ai/schemastery' import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } 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, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -259,7 +259,7 @@ 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 - /** Standing grants keyed by the sorted canonical directory set; ACEs outlive the provider. */ + /** Standing grants keyed by canonical directory; ACEs outlive the provider. */ private readonly directoryGrants = new Map() /** Revocable private temp grants per live session/workspace pair. */ private readonly tempCapabilities = new Map() @@ -370,13 +370,13 @@ export class LocalSandboxProvider extends SandboxProvider { '--temp', temp.dir, '--mode', policy.mode, ...extraArgs, - '--write-sid', workspaceWriteSid(policy.workspaceRoot, roots), + ...roots.flatMap(root => ['--write-sid', directoryWriteSid(root)]), '--temp-write-sid', temp.writeSid, ] } /** - * Resolve or materialize the exact writable-directory set's standing ACEs. + * Resolve or materialize each permitted directory's standing ACE. * Private temp capabilities remain scoped to the session/workspace pair; * changing directory sets does not revoke grants held by running processes. * Failed temp materialization revokes its grant and removes its directory. @@ -390,24 +390,24 @@ export class LocalSandboxProvider extends SandboxProvider { const key = JSON.stringify([String(sessionId), workspaceRoot]) const existing = this.tempCapabilities.get(key) if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir) - const writeSid = workspaceWriteSid(workspaceRoot, roots) - const grantKey = JSON.stringify([...roots].sort()) - const cachedGrant = this.directoryGrants.get(grantKey) - const directoryGrant = cachedGrant ?? AclWriteGrant.create(writeSid) - try { - for (const root of roots) directoryGrant.add(root, true) - } catch (error) { - if (cachedGrant !== undefined) throw error - // Standing ACEs remain reusable; only the failed entry's allocations - // are released. A later execution retries the complete directory set. + for (const root of roots) { + const cachedGrant = this.directoryGrants.get(root) + const directoryGrant = cachedGrant ?? AclWriteGrant.create(directoryWriteSid(root)) try { - directoryGrant.dispose() - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed') + directoryGrant.add(root, true) + } catch (error) { + if (cachedGrant !== undefined) throw error + // Standing ACEs remain reusable; only the failed entry's allocations + // are released. A later execution retries this directory's grant. + try { + directoryGrant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed') + } + throw error } - throw error + this.directoryGrants.set(root, directoryGrant) } - this.directoryGrants.set(grantKey, directoryGrant) if (existing !== undefined) return existing const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) const tempSid = tempWriteSid(tempDir) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 63020397cf..6ed6d2a7d3 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,6 +1,6 @@ /** * windows-acl grant ownership through the real LocalSandboxProvider: one - * standing capability per workspace plus one random, distinct, revocable + * standing capability per directory plus one random, distinct, revocable * temp capability per live session/workspace pair. The Win32 grant surface * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ @@ -13,7 +13,7 @@ import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' -import { workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ @@ -85,6 +85,10 @@ function flag(argv: readonly string[], name: string): string | undefined { return index < 0 ? undefined : argv[index + 1] } +function flags(argv: readonly string[], name: string): string[] { + return argv.filter((_value, index) => index > 0 && argv[index - 1] === name) +} + describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] @@ -132,19 +136,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(tempDir).toBeDefined() expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) expect(tempSid).toBe(`TEMP:${tempDir}`) - expect(tempSid).not.toBe(workspaceWriteSid(ws)) + expect(tempSid).not.toBe(directoryWriteSid(ws)) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', workspaceWriteSid(ws), + '--write-sid', directoryWriteSid(ws), '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toEqual([ - expect.objectContaining({ writeSid: workspaceWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: directoryWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }), expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), ]) expect(existsSync(tempDir ?? '')).toBe(true) @@ -160,38 +164,45 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('selects exact directory-set grants while retaining session-private temp capabilities', async () => { + it('reuses each directory grant across changing sets and workspaces without granting sibling roots', async () => { const { sandbox, fiber } = await setup() try { const ws = workspaceRoot() const memory = workspaceRoot() const cache = workspaceRoot() - scratch.push(ws, memory, cache) + const otherWorkspace = workspaceRoot() + scratch.push(ws, memory, cache, otherWorkspace) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') } const base = await sandbox.confine(['true'], policy) const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] }) - const expandedSid = workspaceWriteSid(ws, [memory, cache]) - expect(flag(expanded.argv, '--write-sid')).toBe(expandedSid) - expect(expandedSid).not.toBe(flag(base.argv, '--write-sid')) + expect(flags(expanded.argv, '--write-sid')).toEqual([ws, memory, cache].map(directoryWriteSid)) expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp')) expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid')) - expect(mockState.grants.filter(grant => grant.writeSid === expandedSid)).toEqual([ - expect.objectContaining({ added: [ws, memory, cache].map(path => ({ path, standing: true })), disposed: false }), - ]) + expect(mockState.grants).toHaveLength(4) + const memoryGrant = mockState.grants.find(grant => grant.writeSid === directoryWriteSid(memory))! const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] }) - expect(flag(reordered.argv, '--write-sid')).toBe(expandedSid) - expect(mockState.grants).toHaveLength(3) + expect(flags(reordered.argv, '--write-sid')).toEqual([ws, cache, memory].map(directoryWriteSid)) + expect(mockState.grants).toHaveLength(4) expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv) expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') }) - expect(flag(sibling.argv, '--write-sid')).toBe(flag(base.argv, '--write-sid')) + expect(flags(sibling.argv, '--write-sid')).toEqual([directoryWriteSid(ws)]) expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid')) - expect(mockState.grants).toHaveLength(4) + const other = await sandbox.confine(['true'], { + ...policy, sessionId: SessionId('other'), workspaceRoot: otherWorkspace, additionalWritableRoots: [memory], + }) + expect(flags(other.argv, '--write-sid')).toEqual([otherWorkspace, memory].map(directoryWriteSid)) + expect(mockState.grants).toHaveLength(7) + expect(mockState.grants.filter(grant => grant.writeSid === directoryWriteSid(memory))).toEqual([memoryGrant]) + for (const root of [ws, memory, cache, otherWorkspace]) { + const grant = mockState.grants.find(candidate => candidate.writeSid === directoryWriteSid(root))! + expect(grant.added.every(added => added.path === root && added.standing)).toBe(true) + } } finally { await fiber.dispose(); cleanup() } }) - it('reapplies a cached set grant when an extra directory is recreated', async () => { + it('reapplies a cached directory grant when an extra directory is recreated', async () => { const { sandbox, fiber } = await setup() try { const ws = workspaceRoot() @@ -201,18 +212,18 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra], } const first = await sandbox.confine(['true'], policy) - const directoryGrant = mockState.grants[0]! + const directoryGrant = mockState.grants[1]! rmSync(extra, { recursive: true }) mkdirSync(extra) const second = await sandbox.confine(['true'], policy) expect(second.argv).toEqual(first.argv) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toBe(directoryGrant) - expect(directoryGrant.added).toEqual([ws, extra, ws, extra].map(path => ({ path, standing: true }))) + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[1]).toBe(directoryGrant) + expect(directoryGrant.added).toEqual([extra, extra].map(path => ({ path, standing: true }))) } finally { await fiber.dispose(); cleanup() } }) - it('retries every directory after a partial set grant fails', async () => { + it('retains successful directory grants and retries the directory that failed', async () => { const { sandbox, fiber } = await setup() try { const ws = workspaceRoot() @@ -224,23 +235,20 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = new Error('extra grant failed') mockState.addFailurePath = extra await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ - added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: true, - }) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: true }) mockState.addFailure = undefined await sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[1]).toMatchObject({ - added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: false, - }) + expect(mockState.grants).toHaveLength(4) + expect(mockState.grants[2]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: false }) mockState.addFailure = new Error('cached grant refresh failed') await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed') - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[1]!.disposed).toBe(false) + expect(mockState.grants).toHaveLength(4) + expect(mockState.grants[2]!.disposed).toBe(false) mockState.addFailure = undefined await sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(3) + expect(mockState.grants).toHaveLength(4) } finally { await fiber.dispose(); cleanup() } }) @@ -277,7 +285,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(0) const upgraded = await sandbox.confine(['true'], workspaceWrite) - expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(workspaceWriteSid(ws)) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(directoryWriteSid(ws)) expect(mockState.grants).toHaveLength(2) await sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index e778ee1934..aa6a067db2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -33,14 +33,14 @@ en: c2cde86c85c2c619 zh: a29a114e591f9d3f /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism: - en: 97ef643f608ae7d8 - zh: bf4eb7941f4573f6 + en: 3981364f27a7ce01 + zh: 1ac67fa0481a63d4 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists: - en: 1a4ed5c06a5be74d - zh: 053cb81bd76260ed + en: 5a0d49485508f4a8 + zh: f332d2d2ba2d8170 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner: - en: de2dce4b18f77dad - zh: bbe7eef92b441364 + en: 8515514d01b74c47 + zh: f0c24d2b3bd3abfd /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries: en: ad02570eae90037f zh: fd78c2318de18dda diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 9344325c43..ce151e9269 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -39,7 +39,7 @@ Choose it for Windows compositions that confine subprocess file effects under `r import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) @@ -50,7 +50,6 @@ const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, - writeSid: workspaceWriteSid(workspaceRoot), tempWriteSid: tempWriteSid(tempDir), mode: 'workspace-write', }) @@ -95,11 +94,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `workspaceWriteSid(workspaceRoot, additionalWritableRoots)` hashes the sorted, deduplicated canonical directory set with unambiguous JSON encoding; a workspace-only set preserves its existing SID. Each execution resolves or materializes standing ACEs for that exact set. Adding or removing a directory selects a different SID for new processes, while running processes keep their launch token. Equal sets reuse their standing security descriptor edits through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, directory-set SID, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a 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. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -110,10 +109,10 @@ Authenticated Users is absent from both lists — the WMI namespace security che The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's runner-failure rules match that signature. ```sh -node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] -- +node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- ``` -The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry for the exact directory set, rechecking each root's ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies the directory-set SID against all selected roots and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. +The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 2f11123c00..af080d999b 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -39,7 +39,7 @@ kind: "package-library" import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) @@ -50,7 +50,6 @@ const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, - writeSid: workspaceWriteSid(workspaceRoot), tempWriteSid: tempWriteSid(tempDir), mode: 'workspace-write', }) @@ -97,11 +96,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`workspaceWriteSid(workspaceRoot, additionalWritableRoots)` 对排序、去重后的规范目录集合使用无歧义的 JSON 编码求哈希;仅含工作区的集合保留现有 SID。每次执行查找或物化完全匹配该集合的常驻 ACE。增加或移除目录会为新进程选择不同的 SID,运行中的进程保留启动令牌。相同集合通过精确 ACE/精确拒绝/精确标签跳过,复用常驻安全描述符改动。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 -`workspace-write`(登录 SID、Everyone、目录集合 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 +`workspace-write`(登录 SID、Everyone、目录 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。 @@ -112,10 +111,10 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全 面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 runner 失败规则匹配该签名。 ```sh -node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] -- +node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- ``` -seam 通过重复的 `--writable-root` 参数传入额外根目录,并为完全相同的目录集合复用授权项,每次执行前重新检查各根目录的 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 根据全部选定根目录验证目录集合 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 +seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 2316b0f6ed..e3f0423530 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,7 +1,7 @@ /** * Server-side write-grant materialization. The sandbox seam holds one - * standing grant per writable-directory set and one revocable temp grant per - * live session/workspace pair. Directory-set identities survive by deterministic + * standing grant per writable directory and one revocable temp grant per + * live session/workspace pair. Directory identities survive by deterministic * derivation and their standing ACE; temp identities derive from random * private paths and are deliberately new after a restart. * diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 9413dc60c2..6b5b51910f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -13,11 +13,12 @@ * for the complete boundary). The intersection covers only the object's own * access check, so the token is also lowered to Low integrity and every * granted directory carries a Low no-write-up label and the ambient-delete - * deny the `acl` module documents. The write SID identifies the complete - * canonical writable-directory set ({@link workspaceWriteSid}). Standing - * ACEs are reusable across calls and sessions with that exact set. Each - * private temp directory instead receives its own SID, so sibling sessions - * cannot use a shared directory-set capability to enter one another's temp + * deny the `acl` module documents. Each canonical writable directory has a + * stable SID ({@link directoryWriteSid}) granted only to that directory. + * An execution carries the SIDs of its allowed directories; standing ACEs + * are reusable across sessions and permission combinations. Each private + * temp directory instead receives its own SID, so sibling sessions cannot + * use a shared directory capability to enter one another's temp * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * @@ -41,7 +42,7 @@ * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { existsSync, statSync } from 'node:fs' +import { existsSync, realpathSync, statSync } from 'node:fs' import { resolve } from 'node:path' import { Win32Error } from '@deepseek-ai/dsh-win32-process' @@ -55,7 +56,9 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts' -export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' +import { directoryWriteSid } from './workspace-sid.ts' + +export { tempWriteSid, directoryWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { @@ -67,18 +70,10 @@ export interface AclSandboxOptions { * root is never an implicit grant. Read-only accepts only null/undefined. */ tempDir?: string | null - /** - * 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 complete canonical writable-directory set via - * {@link workspaceWriteSid}. Standing ACEs outlive sandbox instances and - * are reusable by later executions carrying that exact set identity. - */ - writeSid?: string /** * The private temp directory's write SID. Required whenever * workspace-write grants a temp directory, absent otherwise. It must be - * distinct from {@link writeSid}, so sibling sessions sharing a workspace + * distinct from every directory write SID, so sibling sessions sharing a workspace * cannot use the standing workspace capability in one another's temp tree. */ tempWriteSid?: string @@ -160,9 +155,9 @@ function freeSidBestEffort( */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ - readonly writableDirs: string[] - /** The directory-set SID whose ACEs form the writable allowlist. */ - readonly writeSid: string | undefined + readonly writableDirs: readonly string[] + /** The independently derived write SIDs, in the same order as writableDirs; empty under read-only. */ + readonly writeSids: readonly string[] /** The private temp directory's write SID (workspace-write with temp only). */ readonly tempWriteSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ @@ -172,7 +167,7 @@ export class AclSandbox { private tempDirResolved: string | null | undefined private api: Win32Bindings | undefined private token: NativePtr | undefined - private writeSidPtr: NativePtr | undefined + private readonly directorySidPtrs = new Map() private tempWriteSidPtr: NativePtr | undefined /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */ private sidAllocations: NativePtr[] = [] @@ -181,26 +176,23 @@ export class AclSandbox { constructor(options: AclSandboxOptions) { this.mode = options.mode this.manageDacls = options.manageDacls ?? true - this.writableDirs = options.writableDirs.map((directory) => { + this.writableDirs = [...new Set(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 - }) + return realpathSync.native(absolute) + }))] this.tempDirOption = options.tempDir - this.writeSid = options.writeSid + this.writeSids = this.mode === 'workspace-write' ? this.writableDirs.map(directoryWriteSid) : [] this.tempWriteSid = options.tempWriteSid - if (this.mode === 'workspace-write' && this.writeSid === undefined) { - throw new Error('AclSandbox workspace-write requires a write SID — derive it from the writable directories via workspaceWriteSid()') - } if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') } if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) { throw new Error('AclSandbox read-only does not accept a temp directory') } - if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) { + if (this.mode === 'read-only' && this.tempWriteSid !== undefined) { throw new Error('AclSandbox read-only does not accept write SIDs') } if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) { @@ -209,7 +201,7 @@ export class AclSandbox { if (this.tempDirOption === null && this.tempWriteSid !== undefined) { throw new Error('AclSandbox temp write SID requires a temp directory') } - if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) { + if (this.tempWriteSid !== undefined && this.writeSids.includes(this.tempWriteSid)) { throw new Error('AclSandbox workspace and temp write SIDs must be distinct') } } @@ -236,7 +228,11 @@ export class AclSandbox { if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) return parsedSid } - this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid) + if (this.mode === 'workspace-write') { + for (const directory of this.writableDirs) { + this.directorySidPtrs.set(directory, parseSid(directoryWriteSid(directory))) + } + } this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption @@ -254,7 +250,7 @@ export class AclSandbox { // 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 - // directory-set reuse cache — dispose() never revokes them, or the next + // per-directory 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 before the private directory is // deleted; the ambient temp root is never granted). @@ -264,9 +260,9 @@ export class AclSandbox { this.sidAllocations.push(lowLabelSid, worldSid) if (this.manageDacls) { - if (this.writeSidPtr !== undefined) { - for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid) + if (this.mode === 'workspace-write') { + for (const [path, sidPtr] of this.directorySidPtrs) { + grantWrite(api, path, sidPtr, lowLabelSid, worldSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful @@ -279,7 +275,8 @@ export class AclSandbox { } const logonSid = findLogonSid(api, currentToken) this.sidAllocations.push(logonSid) - const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) + const writeSids = [...this.directorySidPtrs.values()] + if (this.tempWriteSidPtr !== undefined) writeSids.push(this.tempWriteSidPtr) restrictedToken = createRestrictedToken( api, currentToken, logonSid, writeSids, { world: worldSid }, @@ -299,7 +296,7 @@ export class AclSandbox { // DACL passes pass-2. Choosing the temp SID prevents default-DACL // objects in one session's temp tree from acquiring the shared // workspace capability. - setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid) + setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.directorySidPtrs.values().next().value ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') currentTokenOpen = false this.api = api @@ -322,14 +319,13 @@ export class AclSandbox { cleanupFailures.push(cleanupError) } } - for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { - freeSidBestEffort(api, sidPtr, label, cleanupFailures) - } + for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', cleanupFailures) + freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', cleanupFailures) for (const sidPtr of this.sidAllocations.splice(0)) { freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures) } this.token = undefined - this.writeSidPtr = undefined + this.directorySidPtrs.clear() this.tempWriteSidPtr = undefined this.tempDirResolved = undefined this.grantedPaths = [] @@ -417,9 +413,8 @@ export class AclSandbox { } } } - for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { - freeSidBestEffort(api, sidPtr, label, failures) - } + for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', failures) + freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', failures) const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always has its token; the guard mirrors the write-SID guard. */ @@ -435,7 +430,7 @@ export class AclSandbox { } this.api = undefined this.token = undefined - this.writeSidPtr = undefined + this.directorySidPtrs.clear() this.tempWriteSidPtr = undefined this.grantedPaths = [] if (failures.length > 0) { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 155c5a5c82..62cb1b40f3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,7 +1,7 @@ /** * 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 directory-set write-SID allowlist, spawns the + * WRITE_RESTRICTED token with the per-directory 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). @@ -10,11 +10,10 @@ * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , * '--mode', , ['--writable-root', ]..., - * ['--write-sid', , - * '--temp-write-sid', ], '--', ] + * ['--write-sid', ]..., ['--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: the writable-directory set and private temp carry distinct + * - workspace-write: each writable directory and private temp carry distinct * capability-SID Write grants; other ACL-addressable writes are denied * except for the documented Everyone and hard-link boundaries. * - read-only: no capability-SID grants; the restricting list carries no @@ -24,11 +23,11 @@ * Public tree writes are denied); the two lists share the keep-alive group * (logon SID, EVERYONE) and differ only by the capabilities. * - * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the - * CALLER has already materialized distinct workspace and private-temp ACEs + * Repeated `--write-sid` plus `--temp-write-sid`: the seam's grant contract — the + * CALLER has already materialized per-directory and private-temp ACEs * and owns their revocation, so the runner neither grants nor revokes - * (`manageDacls: false`). Both values are checked against their owning directory set or private path. - * Without the pair (standalone/agentless use), workspace-write treats + * (`manageDacls: false`). Each SID is checked against its owning directory path. + * Without these SID flags (standalone/agentless use), workspace-write treats * `--temp` as a ROOT, creates a random private child directory, derives its * own temp SID, and removes that directory after the child exits. In both * flows the runner rewrites TMP/TEMP in its OWN environment to the private @@ -45,12 +44,12 @@ */ import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control' -import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' +import { closeSync, existsSync, mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { win32 } from './ffi.ts' import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts' -import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' +import { tempWriteSid, directoryWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -68,7 +67,7 @@ interface ParsedArgs { temp: string mode: 'read-only' | 'workspace-write' writableRoots: string[] - writeSid: string | undefined + writeSids: string[] tempWriteSid: string | undefined command: string args: string[] @@ -79,7 +78,7 @@ function parseArgs(raw: string[]): ParsedArgs { let workspace: string | undefined let temp: string | undefined let mode: string | undefined - let writeSid: string | undefined + const writeSids: string[] = [] let parsedTempWriteSid: string | undefined let index = 0 for (; index < raw.length; index++) { @@ -96,7 +95,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--writable-root': writableRoots.push(value); break case '--temp': temp = value; break case '--mode': mode = value; break - case '--write-sid': writeSid = value; break + case '--write-sid': writeSids.push(value); break case '--temp-write-sid': parsedTempWriteSid = value; break default: fail(`unknown argument: ${token}`) } @@ -107,7 +106,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, writableRoots, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writableRoots, writeSids, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -123,16 +122,16 @@ async function main(): Promise { requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) for (const root of parsed.writableRoots) requireDirectory('--writable-root', root) - const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots])] + const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots].map(directory => realpathSync.native(directory)))] - const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined + const seamManaged = parsed.writeSids.length > 0 || parsed.tempWriteSid !== undefined if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) { fail('read-only does not accept --writable-root') } if (parsed.mode === 'read-only' && seamManaged) { fail('read-only does not accept --write-sid or --temp-write-sid') } - if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { + if (parsed.mode === 'workspace-write' && (parsed.writeSids.length === 0) !== (parsed.tempWriteSid === undefined)) { fail('workspace-write requires --write-sid and --temp-write-sid together') } if (parsed.mode === 'workspace-write') { @@ -152,12 +151,13 @@ async function main(): Promise { let initialized = false try { let privateTempDir: string | null = null - let writeSid: string | undefined let privateTempSid: string | undefined if (parsed.mode === 'workspace-write') { - writeSid = workspaceWriteSid(parsed.workspace, parsed.writableRoots) if (seamManaged) { - if (parsed.writeSid !== writeSid) fail('--write-sid does not match writable directories') + if (parsed.writeSids.length !== writableDirs.length + || writableDirs.some((directory, index) => parsed.writeSids[index] !== directoryWriteSid(directory))) { + fail('--write-sid must match each writable directory in order') + } privateTempDir = parsed.temp privateTempSid = tempWriteSid(privateTempDir) if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') @@ -171,7 +171,6 @@ async function main(): Promise { writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [], tempDir: privateTempDir, mode: parsed.mode, - ...writeSid === undefined ? {} : { writeSid }, ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid }, manageDacls: !seamManaged, }) diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 25e06ae3f4..06ad9690f0 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -182,7 +182,7 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (verified on Win11 26200, see the POC-worktree restrict-variant harness): * - read-only: [logon SID, EVERYONE] - * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID] + * - workspace-write: [logon SID, EVERYONE, ...directory SIDs, optional temp SID] * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — @@ -207,7 +207,7 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSids - the distinct write SIDs forming the workspace and + * @param writeSids - the distinct write SIDs forming the directory and * optional temp allowlists (workspace-write only; empty under read-only). * @param known - the well-known SIDs entering the restricting list. * @param mode - selects the restricting list (workspace-write adds the capability SIDs). diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index 58c9c0dd4e..1a446e5cbf 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -1,24 +1,19 @@ /** - * Deterministic write identities for canonical writable-directory sets and - * separate identities for private temp directories. Standing directory ACEs - * are reusable only by executions carrying the same complete set identity. + * Stable write identities for individual canonical directories, with separate + * identities for private temp directories. * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid */ import { createHash } from 'node:crypto' /** - * Derive the write SID for a workspace and its additional writable roots. - * Order and duplicate entries do not affect the identity. A workspace-only - * set retains its existing SID; larger sets use unambiguous JSON encoding. - * @param workspaceRoot - the canonical workspace path. - * @param additionalWritableRoots - canonical extra directories; defaults to none. - * @returns the deterministic SDDL string for the complete directory set. + * Derive one directory's write SID. Grant this SID only on that directory; + * executions carry the SIDs of all directories they are allowed to write. + * @param directory - the canonical directory path. + * @returns the deterministic SDDL string, preserving existing workspace identities. */ -export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots: readonly string[] = []): string { - const roots = [...new Set([workspaceRoot, ...additionalWritableRoots])].sort() - const identity = roots.length === 1 ? workspaceRoot : JSON.stringify(roots) - const digest = createHash('sha256').update(identity, 'utf8').digest() +export function directoryWriteSid(directory: string): string { + const digest = createHash('sha256').update(directory, '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}` @@ -27,7 +22,7 @@ export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots /** * Derive one private temp directory's write SID. The random directory path * is the capability identity; a fixed third subauthority domain-separates - * the result from every two-subauthority workspace SID. + * the result from every two-subauthority directory SID. * @param tempDir - the private temp directory's absolute path. * @returns the SDDL string form. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 3bf86f4d43..255f15c5dc 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,14 +9,14 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' -import { AclSandbox } from '../src/index.ts' +import { AclSandbox, directoryWriteSid } from '../src/index.ts' import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' @@ -209,7 +209,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { }) function scratch(): string { - const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')) + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-edit-'))) scratchDirs.push(dir) return dir } @@ -364,11 +364,11 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) - it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => { + it('interleaved sandbox instances reuse one standing directory ACE', 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' }) + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() // Workspace ACEs are STANDING: dispose frees the instance's SID @@ -377,8 +377,29 @@ describe.skipIf(!isWin32)('ACL editing', () => { sandboxA.dispose() sandboxB.dispose() const aces = readDirectAces(api, dir) - 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) + expect(aces.filter(ace => ace.sid === directoryWriteSid(dir))).toHaveLength(1) + }) + + it('shares a directory capability across sandbox sets without granting it on their other directories', async () => { + const api = await win32() + const workspaceA = scratch() + const workspaceB = scratch() + const memory = scratch() + const sandboxA = new AclSandbox({ writableDirs: [workspaceA, memory], tempDir: null, mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [workspaceB, memory], tempDir: null, mode: 'workspace-write' }) + try { + await sandboxA.init() + await sandboxB.init() + const dirs = [workspaceA, workspaceB, memory] + const directorySids = dirs.map(directoryWriteSid) + for (const dir of dirs) { + const granted = readDirectAces(api, dir).filter(ace => directorySids.includes(ace.sid)) + expect(granted.map(ace => ace.sid)).toEqual([directoryWriteSid(dir)]) + } + } finally { + sandboxA.dispose() + sandboxB.dispose() + } }) it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => { @@ -388,14 +409,13 @@ describe.skipIf(!isWin32)('ACL editing', () => { const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, - writeSid: 'S-1-4-9000-3', tempWriteSid: 'S-1-4-9000-3-1', 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) + expect(workspaceAces.some(ace => ace.sid === directoryWriteSid(workspaceDir))).toBe(true) expect(readLabelAces(api, workspaceDir)).toEqual([ { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, ]) @@ -407,13 +427,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { it('rejects an overlapping private temp directory before applying either capability', async () => { const workspaceDir = scratch() const nestedTemp = join(workspaceDir, 'temp') - const writeSid = 'S-1-4-9000-30' + const writeSid = directoryWriteSid(workspaceDir) const privateTempSid = 'S-1-4-9000-30-1' mkdirSync(nestedTemp) const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir: nestedTemp, - writeSid, tempWriteSid: privateTempSid, mode: 'workspace-write', }) @@ -424,19 +443,16 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false) }) - it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { + it('workspace-write requires explicit temp authority and a nonempty token capability list', () => { const dir = scratch() - expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) - .toThrow(/requires a write SID/) - expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + expect(() => new AclSandbox({ writableDirs: [dir], mode: 'workspace-write' })) .toThrow(/requires an explicit private temp directory or null/) - expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, mode: 'workspace-write' })) .toThrow(/requires a temp write SID/) expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, - writeSid: 'S-1-4-1-1', - tempWriteSid: 'S-1-4-1-1', + tempWriteSid: directoryWriteSid(dir), mode: 'workspace-write', })).toThrow(/must be distinct/) expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) @@ -497,7 +513,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', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, 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/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 64aac56080..2b39040490 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -8,7 +8,7 @@ * acl.spec.ts and runner.spec.ts (win32 only). */ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Win32Error } from '@deepseek-ai/dsh-win32-process' @@ -18,7 +18,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { AclSandbox } from '../src/index.ts' +import { AclSandbox, directoryWriteSid } from '../src/index.ts' import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') @@ -29,6 +29,7 @@ type MockFn = ReturnType interface HappyStubs { api: Win32Bindings setNamedSecurityInfoW: MockFn + setEntriesInAclW: MockFn convertStringSidToSidW: MockFn closeHandle: MockFn localFree: MockFn @@ -178,7 +179,7 @@ function happyStubs(): HappyStubs { localFree, closeHandle, getLastError, formatMessageW, }) return { - api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, + api, setNamedSecurityInfoW, setEntriesInAclW, convertStringSidToSidW, closeHandle, localFree, createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, } } @@ -197,18 +198,27 @@ describe('AclSandbox constructor validation', () => { it('resolves relative writable directories to absolute paths', () => { const dir = scratch() const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) - expect(sandbox.writableDirs).toEqual([resolve(dir)]) + expect(sandbox.writableDirs).toEqual([realpathSync.native(dir)]) expect(sandbox.mode).toBe('read-only') expect(sandbox.tempDir).toBeUndefined() }) + it('derives one SID per canonical directory and keeps read-only free of directory capabilities', () => { + const workspace = scratch() + const memory = scratch() + const dirs = [workspace, memory, workspace] + const sandbox = new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'workspace-write' }) + const canonical = [workspace, memory].map(path => realpathSync.native(path)) + expect(sandbox.writableDirs).toEqual(canonical) + expect(sandbox.writeSids).toEqual(canonical.map(directoryWriteSid)) + expect(new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'read-only' }).writeSids).toEqual([]) + }) + it('rejects temp authority under read-only', () => { const workspace = scratch() const temp = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' })) .toThrow(/read-only does not accept a temp directory/u) - expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' })) - .toThrow(/read-only does not accept write SIDs/u) expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' })) .toThrow(/read-only does not accept write SIDs/u) }) @@ -218,7 +228,6 @@ describe('AclSandbox constructor validation', () => { expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, - writeSid: 'S-1-4-9000-2', tempWriteSid: 'S-1-4-9000-2-1', mode: 'workspace-write', })).toThrow(/temp write SID requires a temp directory/u) @@ -233,7 +242,6 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, - writeSid: 'S-1-4-9000-1', tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write', }) @@ -242,16 +250,48 @@ describe('AclSandbox init', () => { expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) }) + it('places every directory SID and the separate temp SID in the restricted token', async () => { + const { convertStringSidToSidW, createRestrictedToken, setEntriesInAclW } = state.stubs as HappyStubs + const workspace = scratch() + const memory = scratch() + const temp = scratch() + const parsed: bigint[] = [] + convertStringSidToSidW.mockImplementation((_sid: string, slot: NativePtr) => { + const pointer = BigInt(101 + parsed.length) + parsed.push(pointer) + koffi.encode(slot, PVOID, pointer) + return 1 + }) + const sandbox = new AclSandbox({ + writableDirs: [workspace, memory], tempDir: temp, tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write', + }) + try { + await sandbox.init() + const expectedSids = [...sandbox.writeSids, sandbox.tempWriteSid] + expect(convertStringSidToSidW).toHaveBeenCalledTimes(expectedSids.length) + expectedSids.forEach((sid, index) => { + expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything()) + }) + const call = createRestrictedToken.mock.calls[0]! + expect(call[6]).toBe(5) + const entries = call[7] as Buffer + expect(parsed.map((_pointer, index) => entries.readBigUInt64LE((index + 2) * abi.SID_AND_ATTRIBUTES_SIZE))).toEqual(parsed) + const defaultDaclMerge = setEntriesInAclW.mock.calls.find(call => call[2] === 88n)! + const defaultEntry = defaultDaclMerge[1] as Buffer + expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[2]) + } finally { sandbox.dispose() } + }) + it('requires an explicit private temp directory or null under workspace-write', () => { const workspace = scratch() - expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })) + expect(() => new AclSandbox({ writableDirs: [workspace], mode: 'workspace-write' })) .toThrow(/requires an explicit private temp directory or null/u) }) it('applies no grants when the temp dir option is null', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only }) @@ -261,7 +301,6 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), - writeSid: 'S-1-4-9000-4', tempWriteSid: 'S-1-4-9000-4-1', mode: 'workspace-write', }) @@ -269,11 +308,14 @@ describe('AclSandbox init', () => { }) it('builds a read-only token without parsing a write SID or applying grants', async () => { - const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + const { convertStringSidToSidW, setNamedSecurityInfoW, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) + const memory = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'read-only' }) await sandbox.init() expect(convertStringSidToSidW).not.toHaveBeenCalled() + expect(createRestrictedToken.mock.calls[0]![6]).toBe(2) + expect(sandbox.writeSids).toEqual([]) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free }) @@ -281,7 +323,7 @@ describe('AclSandbox init', () => { it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write', manageDacls: false }) await sandbox.init() expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke @@ -289,7 +331,7 @@ describe('AclSandbox init', () => { it('refuses a second init on the same instance', async () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() await expect(sandbox.init()).rejects.toThrow(/already initialized/u) }) @@ -298,23 +340,44 @@ describe('AclSandbox init', () => { const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs convertStringSidToSidW.mockReturnValue(0) const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() }) + it('frees earlier directory SIDs when parsing a later directory SID fails', async () => { + const { convertStringSidToSidW, localFree, setNamedSecurityInfoW, closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const memory = scratch() + const firstSid = 101n as NativePtr + convertStringSidToSidW.mockImplementationOnce((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, firstSid) + return 1 + }).mockReturnValueOnce(0) + const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) + expect(convertStringSidToSidW).toHaveBeenCalledTimes(sandbox.writeSids.length) + sandbox.writeSids.forEach((sid, index) => { + expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything()) + }) + expect(localFree).toHaveBeenCalledWith(firstSid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(closeHandle).toHaveBeenCalledWith(2n) + expect(sandbox.tempDir).toBeUndefined() + }) + it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { const { convertStringSidToSidW } = state.stubs as HappyStubs convertStringSidToSidW.mockImplementation(() => 1) // no out slot write const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) }) it('aggregates failed current and restricted token closes after init', async () => { const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) const restrictedToken = 99n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, @@ -361,7 +424,6 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, - writeSid: 'S-1-4-9000-10', tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) @@ -391,13 +453,13 @@ describe('AclSandbox spawn', () => { it('refuses to spawn before init', () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) }) it('pipe spawn drains empty pipes and settles with the child exit code', async () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) expect(child.pid).toBe(1234) @@ -410,7 +472,7 @@ describe('AclSandbox spawn', () => { it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { const { closeHandle } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) @@ -420,7 +482,7 @@ describe('AclSandbox spawn', () => { it('inherit spawn reports a failed close of the kill-on-close job', async () => { const { closeHandle, createJobObjectW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() let jobHandle = 0n closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) @@ -433,7 +495,7 @@ describe('AclSandbox spawn', () => { describe('AclSandbox dispose', () => { it('is a no-op before init', () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) expect(() => { sandbox.dispose() }).not.toThrow() }) @@ -444,7 +506,6 @@ describe('AclSandbox dispose', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, - writeSid: 'S-1-4-9000-16', tempWriteSid: 'S-1-4-9000-16-1', mode: 'workspace-write', }) @@ -456,7 +517,7 @@ describe('AclSandbox dispose', () => { it('aggregates SID and token cleanup failures into an AggregateError', async () => { const { localFree } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) await sandbox.init() localFree.mockReturnValue(1n) expect(() => { sandbox.dispose() }).toThrow(AggregateError) @@ -465,7 +526,7 @@ describe('AclSandbox dispose', () => { it('reports a failed close of the restricted token', async () => { const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) let restrictedToken = 0n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 117052da1c..dfa56d1520 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -52,7 +52,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, - writeSid: 'S-1-4-9000-4', tempWriteSid: 'S-1-4-9000-4-1', mode: 'workspace-write', }) @@ -92,15 +91,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () 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 () => { + it('fails closed when the temp 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], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) it('failed init clears provisional temp state before a retry', async () => { - const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' }) const provisionalState = broken as unknown as { tempDirResolved: string | undefined } provisionalState.tempDirResolved = isolatedTemp diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 413afe2f69..b639cac114 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -6,14 +6,14 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, 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' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' -import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts' +import { AclWriteGrant, tempWriteSid, directoryWriteSid } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -48,10 +48,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let publicProbeDir: string | undefined beforeAll(() => { - scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) + scratchRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))) writableDir = join(scratchRoot, 'writable') mkdirSync(writableDir) - isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')) + isolatedTemp = realpathSync.native(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') @@ -135,21 +135,23 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(readFileSync(extraFile, 'utf8')).toBe('retained') }, 90_000) - it('accepts a provider-materialized SID covering the workspace and additional directories', () => { + it('accepts one provider-materialized SID per writable directory', () => { const extra = mkdtempSync(join(scratchRoot, 'managed-extra-')) const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-')) - const writeSid = workspaceWriteSid(writableDir, [extra]) + const writeSid = directoryWriteSid(writableDir) + const extraSid = directoryWriteSid(extra) const privateTempSid = tempWriteSid(privateTemp) const directoryGrant = AclWriteGrant.create(writeSid) + const extraGrant = AclWriteGrant.create(extraSid) const tempGrant = AclWriteGrant.create(privateTempSid) try { directoryGrant.add(writableDir, true) - directoryGrant.add(extra, true) + extraGrant.add(extra, true) tempGrant.add(privateTemp) const target = join(extra, 'managed-write.txt') const result = runRunner([ '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', - '--writable-root', extra, '--write-sid', writeSid, '--temp-write-sid', privateTempSid, + '--writable-root', extra, '--write-sid', writeSid, '--write-sid', extraSid, '--temp-write-sid', privateTempSid, '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target, ]) expect(result.error).toBeUndefined() @@ -158,6 +160,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(readFileSync(target, 'utf8')).toBe('managed') } finally { directoryGrant.dispose() + extraGrant.dispose() tempGrant.dispose() rmSync(privateTemp, { recursive: true, force: true }) } @@ -222,7 +225,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => { const seamWorkspace = join(scratchRoot, 'seam-workspace') mkdirSync(seamWorkspace) - const writeSid = workspaceWriteSid(seamWorkspace) + const writeSid = directoryWriteSid(seamWorkspace) const privateTemp = join(isolatedTemp, 'private-subdir') mkdirSync(privateTemp) const privateTempSid = tempWriteSid(privateTemp) @@ -258,7 +261,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 30_000) it('temp capabilities isolate sibling sessions that share one workspace SID', () => { - const writeSid = workspaceWriteSid(writableDir) + const writeSid = directoryWriteSid(writableDir) const tempA = join(isolatedTemp, 'session-a') const tempB = join(isolatedTemp, 'session-b') mkdirSync(tempA) @@ -389,7 +392,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // it, so the workspace write is denied instead of leaking through the // standing ACE. The switch back reuses the SAME standing ACE: the // re-upgrade write lands without any re-grant. - const writeSid = workspaceWriteSid(writableDir) + const writeSid = directoryWriteSid(writableDir) const privateTemp = join(isolatedTemp, 'mode-switch-temp') mkdirSync(privateTemp) const privateTempSid = tempWriteSid(privateTemp) @@ -506,7 +509,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const otherTemp = join(scratchRoot, 'other-temp') mkdirSync(otherWorkspace) mkdirSync(otherTemp) - const otherWorkspaceSid = workspaceWriteSid(otherWorkspace) + const otherWorkspaceSid = directoryWriteSid(otherWorkspace) const otherTempSid = tempWriteSid(otherTemp) const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid) const otherTempGrant = AclWriteGrant.create(otherTempSid) @@ -517,7 +520,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const ownTemp = join(scratchRoot, 'own-temp') mkdirSync(ownWorkspace) mkdirSync(ownTemp) - const ownWorkspaceSid = workspaceWriteSid(ownWorkspace) + const ownWorkspaceSid = directoryWriteSid(ownWorkspace) const ownTempSid = tempWriteSid(ownTemp) const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid) const ownTempGrant = AclWriteGrant.create(ownTempSid) @@ -594,7 +597,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { mkdirSync(child) writeFileSync(join(granted, 'file.txt'), 'x') writeFileSync(join(child, 'deep.txt'), 'x') - const grant = AclWriteGrant.create(workspaceWriteSid(granted)) + const grant = AclWriteGrant.create(directoryWriteSid(granted)) grant.add(granted, true) try { const probe = ` @@ -629,7 +632,7 @@ TryOpen 'DIRECTORY' '${child}' // not strip the label the second one's child still writes through. const shared = join(scratchRoot, 'shared-root') mkdirSync(shared) - const sidA = workspaceWriteSid(shared) + const sidA = directoryWriteSid(shared) const sidB = `${sidA}-1` const grantA = AclWriteGrant.create(sidA) const grantB = AclWriteGrant.create(sidB) @@ -711,11 +714,13 @@ TryOpen 'DIRECTORY' '${child}' }, 90_000) it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { - const writeSid = workspaceWriteSid(writableDir) + const writeSid = directoryWriteSid(writableDir) const tempSid = tempWriteSid(isolatedTemp) const cases = [ ['--write-sid', writeSid], ['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid], + ['--writable-root', worldWritableDir, '--write-sid', directoryWriteSid(worldWritableDir), '--write-sid', writeSid, '--temp-write-sid', tempSid], + ['--write-sid', writeSid, '--write-sid', directoryWriteSid(worldWritableDir), '--temp-write-sid', tempSid], ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], ] diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 82b9d055e0..bbb88ca38c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -1,48 +1,24 @@ -/** - * 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), capability-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). - */ +/** Stable, distinct write identities for canonical directories and private temp paths. */ import { describe, expect, it } from 'vitest' -import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' +import { directoryWriteSid, tempWriteSid } from '../src/index.ts' -describe('workspaceWriteSid', () => { - it('derives a stable capability-shaped SID per workspace path', () => { - const first = workspaceWriteSid('C:\\Users\\agent\\repo') - const second = workspaceWriteSid('C:\\Users\\agent\\repo') +describe('directoryWriteSid', () => { + it('derives a stable capability-shaped SID per directory path', () => { + const first = directoryWriteSid('C:\\Users\\agent\\repo') + const second = directoryWriteSid('C:\\Users\\agent\\repo') expect(first).toBe(second) expect(first).toMatch(/^S-1-4-\d+-\d+$/u) }) - it('uses the same identity for reordered and duplicated writable-directory sets', () => { - const workspace = 'C:\\repo' - const memory = 'C:\\memory' - const cache = 'C:\\cache' - const sid = workspaceWriteSid(workspace, [memory, cache]) - expect(workspaceWriteSid(workspace, [cache, workspace, memory, cache])).toBe(sid) - expect(workspaceWriteSid(memory, [workspace, cache])).toBe(sid) - expect(workspaceWriteSid(workspace, [workspace])).toBe(workspaceWriteSid(workspace)) - expect(sid).not.toBe(workspaceWriteSid(workspace)) - expect(sid).not.toBe(workspaceWriteSid(workspace, [memory])) - }) - - it('keeps path boundaries distinct when their concatenated text matches', () => { - expect(workspaceWriteSid('C:\\a', ['C:\\bC:\\c'])) - .not.toBe(workspaceWriteSid('C:\\aC:\\b', ['C:\\c'])) - }) - - it('derives distinct identities for distinct workspaces', () => { - expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) + it('derives distinct identities for distinct directories', () => { + expect(directoryWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(directoryWriteSid('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')) + expect(directoryWriteSid('C:\\Repo')).not.toBe(directoryWriteSid('c:\\repo')) + expect(directoryWriteSid('C:\\Repo\\')).not.toBe(directoryWriteSid('C:\\Repo')) }) }) @@ -51,7 +27,7 @@ describe('tempWriteSid', () => { const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123') expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u) - expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + expect(temp).not.toBe(directoryWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) }) it('derives distinct capabilities for distinct private temp paths', () => { diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 01d14f51ef..2ee587956b 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -53,7 +53,7 @@ export interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by the complete writable-directory set); absent + * while standing grants are keyed by individual writable directories); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId From 28484a44e15dd1115a75c8e5aeb4b12bd69314e4 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 28 Oct 2026 19:45:01 +0800 Subject: [PATCH 12/16] refactor(sandbox): remove obsolete unsupported-policy error --- packages/sandbox/sandbox-local/src/index.ts | 2 +- packages/sandbox/sandbox/src/index.ts | 8 -------- packages/sandbox/sandbox/tests/vocabulary.spec.ts | 12 +----------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index c24e175548..18acb7cbd4 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -238,7 +238,7 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants - * ({@link AclWriteGrant}: standing grants per writable-directory set + * ({@link AclWriteGrant}: standing grants per writable directory * and the revocable private-temp grant per live session/workspace pair, the * latter revoked on provider dispose); the one-time probes spawn nothing * else. diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 2ee587956b..2d3eb81a5d 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -151,14 +151,6 @@ export class SandboxUnavailableError extends HarnessError { } } -/** The selected backend is available but cannot enforce the requested policy. */ -export class SandboxUnsupportedPolicyError extends HarnessError { - constructor(detail: string) { - super(detail, 'SANDBOX_UNSUPPORTED_POLICY') - this.name = 'SandboxUnsupportedPolicyError' - } -} - declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index 000168e726..caba42b2a7 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,17 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { SANDBOX_UNAVAILABLE, SandboxUnavailableError, SandboxUnsupportedPolicyError } from '@deepseek-ai/dsh-sandbox' - -describe('SandboxUnsupportedPolicyError', () => { - it('distinguishes an unsupported policy from an unavailable backend', () => { - const error = new SandboxUnsupportedPolicyError('The selected backend cannot grant additional directories.') - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('SandboxUnsupportedPolicyError') - expect(error.code).toBe('SANDBOX_UNSUPPORTED_POLICY') - expect(error.message).toBe('The selected backend cannot grant additional directories.') - }) -}) +import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { it('carries the structured { name, code } identity consumers key on', () => { From 2028ef3d26127bf2974a01d6cb416998ba730dee Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Wed, 28 Oct 2026 19:48:42 +0800 Subject: [PATCH 13/16] test(sandbox): inspect the derived directory SID in ACL assertions --- packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 255f15c5dc..d1fa520dd3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -516,7 +516,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }) try { await sandbox.init() - const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') + const grant = readDirectAces(api, dir).find(ace => ace.sid === sandbox.writeSids[0]) expect(grant).toBeDefined() const mask = grant?.mask ?? 0 expect(mask).toBe(abi.GRANT_MASK) From d6e89120d180804f552c5b80f0320d6cee77df08 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 29 Oct 2026 11:05:40 +0800 Subject: [PATCH 14/16] fix(sandbox): compare root paths with host semantics Reuse derived directory SIDs during initialization and align documentation with per-directory grants. --- ...ndows-directory-write-identities.i18n.yaml | 8 ++--- ...0-28-windows-directory-write-identities.md | 4 ++- ...8-windows-directory-write-identities.zh.md | 4 ++- ...ows-acl-restricted-token-sandbox.i18n.yaml | 20 ++++++------ ...08-windows-acl-restricted-token-sandbox.md | 10 +++--- ...windows-acl-restricted-token-sandbox.zh.md | 10 +++--- .../windows-directory-sids/guide.i18n.yaml | 8 ++--- .../windows-directory-sids/guide.md | 7 ++-- .../windows-directory-sids/guide.zh.md | 7 ++-- .../sandbox-windows-acl/README.i18n.yaml | 26 +++++++-------- .../sandbox/sandbox-windows-acl/README.md | 32 +++++++++---------- .../sandbox/sandbox-windows-acl/README.zh.md | 30 ++++++++--------- .../sandbox/sandbox-windows-acl/src/grant.ts | 6 ++-- .../sandbox/sandbox-windows-acl/src/index.ts | 28 ++++++++-------- .../sandbox/sandbox-windows-acl/src/runner.ts | 2 +- .../sandbox/sandbox-windows-acl/src/token.ts | 5 +-- 16 files changed, 108 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml index 583e918ef5..1dcb2d8646 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml @@ -9,11 +9,11 @@ en: ce436e11d96c3c96 zh: dcda6f85413a5be6 /agent-note-windows-per-directory-write-identities/decision: - en: edbc0f1001721065 - zh: a91b9b2c1e19e6d2 + en: 2b778019f042b4c7 + zh: 6477f4b8a4d05cab /agent-note-windows-per-directory-write-identities/alternatives-considered: en: d87e2cf9196d66e7 zh: 04261fa61efe407b /agent-note-windows-per-directory-write-identities/consequences: - en: b9c9e40cf1e09f4e - zh: 6e69f792bbdd7726 + en: f20026b54351fcb4 + zh: 350ff60897c1f4bd diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md index 7076514a10..47bd25f0a2 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md @@ -14,6 +14,8 @@ Each canonical writable directory has one deterministic SID, granted only to tha `AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots. +Directory grants compare supplied and physical paths with Node’s host-specific `path.relative`, while SID derivation retains the physical spelling. This accepts ordinary Windows casing differences without adding a component-walking implementation. The comparison assumes case-insensitive Windows paths; legacy 8.3 aliases remain unsupported. This deliberately favors the common path syntax over exhaustive filesystem-identity handling. + ## Alternatives considered **One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse. @@ -22,4 +24,4 @@ Each canonical writable directory has one deterministic SID, granted only to tha ## Consequences -Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The underlying restricted-token and hard-link limitations remain unchanged. +Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The [Windows ACL decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) retains the restricted-token mechanism and hard-link limitations; this decision replaces its single-workspace identity model. diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md index ecaab18b84..d078334d53 100644 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md @@ -14,6 +14,8 @@ Status: implemented `AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。 +目录授权使用 Node 按主机平台实现的 `path.relative` 比较传入路径与物理路径,SID 派生则保留物理路径的拼写。这接受普通 Windows 大小写差异,无需加入逐级遍历路径的实现。比较假定 Windows 路径不区分大小写;旧式 8.3 别名仍不受支持。这里有意优先支持常见路径语法,而不穷尽文件系统身份处理。 + ## 考虑过的替代方案 **每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。 @@ -22,4 +24,4 @@ Status: implemented ## 影响 -已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。底层受限令牌及硬链接限制保持不变。 +已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。[Windows ACL 决策](../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 index 8217e734a6..ed74eb3498 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 @@ -9,14 +9,14 @@ en: f62d87b7623a9904 zh: 48bbb7c35298f96c /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision: - en: 0cd47aef022d86ad - zh: 1f8c5006db51c97b + en: 90fd9a7cf5e25d03 + zh: 916b1eac07da4eb6 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery: en: a5bcdf2a6133abfe zh: 477d4915d61945e3 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity: - en: 1ba89a61a733b371 - zh: 4fa134ad1b2059a1 + en: 3bc4bd8f6d4588c8 + zh: e4760533aa152a5d /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered: en: 6119e2aff3b6be5a zh: 4237c596c10e1ee9 @@ -30,14 +30,14 @@ en: e2c573d90c4ddeb9 zh: f0259213e1321722 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences: - en: 5b9657f4247b6a79 - zh: e024d34bbab082a7 + en: 883c393d20d42da3 + zh: 541fdf737114b7ae /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing: - en: b223cb2cf13193c9 - zh: 61ddd0a0b99f8f56 + en: 2175317fcb25a52d + zh: 076cee14f261f85e /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related: - en: dd16f0c5815df991 - zh: df3c1cf511ee52bf + en: da294868c980f138 + zh: 8700a7146483b0b8 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped: en: 5262178f0035b215 zh: 10505c394ea33ee3 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 412933dbf1..78297a2d41 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 original [historical sandbox decision](../../archived/feature/2026-07-06-san ## 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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +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` is not used) whose restricting SIDs carry distinct directory and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire a shared directory capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A writable root equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, ...directory SIDs, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing directory ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (otherwise the first directory SID, or Everyone when neither is present) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## ACL diagnosis and recovery @@ -22,7 +22,7 @@ Both extracted and recovery scripts are user-writable, so this workflow never re ## 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 capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. 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. +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 capability-SID check, which only the permitted-directory and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. 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 @@ -40,14 +40,16 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the standing capabilities for their permitted directories but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing directory security-descriptor mutations (the reuse cache, plus old ACEs when a directory is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree directory propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, per-directory and temp argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins directory/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, directory/temp SID validation, sibling temp denial under shared directory SIDs, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related +The [per-directory identity decision](../architecture/2026-10-28-windows-directory-write-identities.md) replaces the single workspace capability with independently reusable directory capabilities; this note retains the restricted-token mechanism and its limitations. + The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. 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 daea838039..38303084b9 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 ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的目录能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的目录能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果可写根目录等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、全部目录 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻目录 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(否则继承第一个目录 SID,两者均不存在时继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## ACL 诊断与恢复 @@ -23,7 +23,7 @@ Status: implemented ## 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` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(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` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 ## 考虑过的替代方案 @@ -41,14 +41,16 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享其获准目录的常驻能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);目录常驻安全描述符改动(复用缓存,以及目录改名后的旧 ACE)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;目录授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、逐目录与临时目录 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住目录/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、目录/临时目录 SID 验证、共享目录 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related +[逐目录身份决策](../architecture/2026-10-28-windows-directory-write-identities.zh.md)将单一工作区能力替换为可独立复用的目录能力;本笔记保留受限令牌机制及其限制。 + [pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 [删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml index 15f12a8dbc..71ce04f47f 100644 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml @@ -9,8 +9,8 @@ en: fd8ac37f696dac64 zh: b5531e3c276b0c88 /windows-acl-directory-identities/change: - en: ae9055a597ae4e32 - zh: 9fe43036ec0372ac + en: 2893ccdcc064e754 + zh: 5ced21fcad53bbaf /windows-acl-directory-identities/migration: - en: 601f6832ab87b74f - zh: 568afc8d964c4da7 + en: f3512bba3f54f294 + zh: 98f2bc1de08c0c8b diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md index fb0ab47ad2..79c3264396 100644 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md @@ -9,11 +9,12 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. Users of the standard `dsh` profiles need no configuration changes. +`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. `writableDirs` is now readonly and contains canonical, deduplicated paths. Windows accepts valid additional writable directories, and `@deepseek-ai/dsh-sandbox` removes `SandboxUnsupportedPolicyError` and its `SANDBOX_UNSUPPORTED_POLICY` code. Users of the standard `dsh` profiles need no configuration changes. ## Migration -1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. +1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. Do not mutate `writableDirs`; construct a new instance for a different directory list. Compare its entries with canonical paths rather than the original input spellings. 2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories. 3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag. -4. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions. +4. Remove imports and catches for `SandboxUnsupportedPolicyError` and checks for `SANDBOX_UNSUPPORTED_POLICY`; Windows supports valid extra directories instead of rejecting that policy. +5. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md index c9bd0b60c4..134debe76b 100644 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md @@ -9,11 +9,12 @@ description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供 ## 变更 -`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。标准 `dsh` profile 的用户无需修改配置。 +`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。`writableDirs` 现为只读,并包含规范化、去重后的路径。Windows 接受有效的额外可写目录,`@deepseek-ai/dsh-sandbox` 移除 `SandboxUnsupportedPolicyError` 及其 `SANDBOX_UNSUPPORTED_POLICY` 错误码。标准 `dsh` profile 的用户无需修改配置。 ## 迁移 -1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。 +1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。不要修改 `writableDirs`;目录列表变化时创建新实例。将其条目与规范路径比较,而非原始输入拼写。 2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。 3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。 -4. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。 +4. 移除 `SandboxUnsupportedPolicyError` 的导入与捕获,以及对 `SANDBOX_UNSUPPORTED_POLICY` 的检查;Windows 支持有效的额外目录,不再拒绝该策略。 +5. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index aa6a067db2..5ae1a26af5 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -21,8 +21,8 @@ en: 510d0b1ef7db159d zh: dc2cf25b3db14c63 /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/direct-api: - en: 16596607bbccc2a3 - zh: 30bc370a5281bf0d + en: 0208dc12f0be12ea + zh: 3ce871ac35bd1828 /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you: en: 0ae33482d28f637b zh: a2b87b18737a6d07 @@ -33,17 +33,17 @@ en: c2cde86c85c2c619 zh: a29a114e591f9d3f /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism: - en: 3981364f27a7ce01 - zh: 1ac67fa0481a63d4 + en: 4ffc57b0582af92c + zh: 8c6fe31d52065f2b /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists: - en: 5a0d49485508f4a8 + en: 60094d4cff20b7cc zh: f332d2d2ba2d8170 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner: - en: 8515514d01b74c47 - zh: f0c24d2b3bd3abfd + en: 7daba96f71c4b02b + zh: a92e6ae40dc118a7 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries: - en: ad02570eae90037f - zh: fd78c2318de18dda + en: 0f87fd641ace67fb + zh: c9fa22ca5225f264 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/header-verification-and-source-map: en: c19df6b7a756b779 zh: b8267b26bd191d97 @@ -57,11 +57,11 @@ en: 817eb1b7de13872e zh: 06fe175e41e7459b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: - en: 046831a864786b2a - zh: 4a261fbab3396ea4 + en: 4cc129728a9005b7 + zh: 89fac806fb43b41b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note/future-warnings-and-cleanup-surfaces: - en: 8872c7b4c8ce1cc7 - zh: c7fb4eefa013c7b0 + en: ea48de0150a65991 + zh: 2b98931436064513 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index ce151e9269..c953838294 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -45,8 +45,8 @@ const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape. workspace-write requires distinct workspace and -// private-temp identities; pass tempDir: null to disable temp writes. +// match the grants. workspace-write derives one SID per writable directory +// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes. const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, @@ -58,11 +58,11 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. +The writable-directory security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you @@ -94,11 +94,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry a capability for each writable directory and a separate private-temp capability, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share authority for their permitted directories without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: standing directory grants from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a 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. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -112,23 +112,23 @@ The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper ` node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- ``` -The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. +The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting a standing directory ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation. ### Verified boundaries - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. -- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working where the directory DACL grants them DELETE directly. - **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite. - **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. -- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. +- **Hard links are file-object aliases, not path aliases** — an inheritable directory grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. -- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. +- **Security descriptor edits are standing directory mutations** — directory ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — each granted directory's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside that directory, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. - **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. -- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. +- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing directory SIDs cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. ### Header verification and source map @@ -178,12 +178,12 @@ One extra catalog entry on Windows: the skill's description enters context with These limits define when the backend is a poor fit or needs special operational care. They are current package constraints, not a general Windows comparison or a task backlog. -- **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. Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. +- **Directory grants remain cached for the provider lifetime** — the provider retains an `AclWriteGrant` and its native SID allocations for every canonical directory it has granted, until provider disposal. New executions select the permitted directory SIDs; changing that selection does not release cached grants or alter running tokens. - **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual removal reclaims it. -- **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), and a future cleanup command may reap them. +- **Standing edits also affect extra directories** — the directory capability ACE, Low label, and Everyone `FILE_DELETE_CHILD` deny persist after plugin removal and provider disposal, including on user directories outside the workspace such as `~/.dsh/memory`. Renaming a directory derives a new SID but leaves the old ACE behind; the standing Low label and deny retain the effects described above. - **NULL-DACL directories are not identity-preserving under grant+revoke** — a directory with a NULL DACL 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. Real workspace and temp directories carry real DACLs, so this stays a documented edge. - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. -- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. +- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-directory identity pays it once per directory per machine, and the exact-ACE skip makes every later provision cheap. - **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. - **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access. - **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. @@ -200,6 +200,6 @@ This Dev Note is working context for maintainers: undecided directions and open #### Future: warnings and cleanup surfaces -The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing workspace ACEs from renamed workspaces is undecided. Both are open directions, not shipped behavior. +The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing directory ACEs from renamed directories is undecided. Both are open directions, not shipped behavior. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index af080d999b..f47ff9aef4 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -45,8 +45,8 @@ const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape. workspace-write requires distinct workspace and -// private-temp identities; pass tempDir: null to disable temp writes. +// match the grants. workspace-write derives one SID per writable directory +// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes. const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, @@ -58,11 +58,11 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants rmSync(tempDir, { recursive: true, force: true }) ``` -工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 +可写目录的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 @@ -96,7 +96,7 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 为每个可写目录携带一项能力,并另带独立的私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享其获准目录的权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 @@ -114,23 +114,23 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全 node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- ``` -seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 +seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻目录 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。 ### 已验证边界 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 -- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,前提是该目录 DACL 直接向其授予 DELETE。 - **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。 - **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 -- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承目录授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 -- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 +- **安全描述符改动是对真实目录的驻留改动。** 目录 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 每个授权目录的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除该目录内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 - **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 -- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 +- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享目录 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 ### 头文件验证与源码索引 @@ -180,12 +180,12 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 这些限制说明后端何时不合适,或何时需要特别运维。它们是当前包约束,不是通用 Windows 对比或任务积压。 -- **每个工作区一个写入白名单**——写入 SID 是白名单的基本单位,且就是工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 +- **目录授权缓存保留到提供方释放**——提供方为每个曾授权的规范目录保留一个 `AclWriteGrant` 及其原生 SID 分配,直到提供方释放。新执行选择获准目录的 SID;改变该选择不会释放缓存授权或修改运行中的令牌。 - **清理按设计尽力而为**——`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。 -- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID),未来的清理命令可以回收它们。 +- **常驻改动同样影响额外目录**——目录能力 ACE、Low 标签与针对 Everyone 的 `FILE_DELETE_CHILD` 拒绝项会在插件移除和提供方释放后保留,包括工作区之外的用户目录,如 `~/.dsh/memory`。目录改名会派生新的 SID,但留下旧 ACE;常驻 Low 标签与拒绝项继续产生上文描述的效果。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形。 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 -- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按目录身份每台机器每个目录只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 @@ -202,6 +202,6 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 #### 未来:警告与清理表面 -对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名工作区常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。 +对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名目录常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index e3f0423530..ae4e486099 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -7,7 +7,7 @@ * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every - * revocable grant, leaves the standing workspace edits in place, and reports + * revocable grant, leaves the standing directory edits in place, and reports * every cleanup failure. * @module @deepseek-ai/dsh-sandbox-windows-acl/grant */ @@ -58,7 +58,7 @@ export class AclWriteGrant { * and the world SID their ambient-delete deny names, and open the binding * table (lazily, once per server). Fail-closed: any failure throws — nothing * is granted yet. - * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. + * @param writeSid - the directory (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). */ @@ -99,7 +99,7 @@ export class AclWriteGrant { * materialization and dispose the instance to revoke the paths granted so * far. * @param path - the directory whose DACL and label gain the grant. - * @param standing - the edits outlive this grant (the workspace reuse + * @param standing - the edits outlive this grant (the directory reuse * cache; dispose() skips revoking it). Default false (revoked on * dispose — the temp-directory lifecycle). */ diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 6b5b51910f..41e5c8500e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,9 +2,9 @@ * 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 distinct workspace and temp write + * token whose restricting SIDs include distinct directory and temp write * SIDs that this sandbox adds to their owning directories' DACLs — the - * intersection check then allows writes exactly where either capability has + * intersection check then allows writes exactly where a permitted capability has * a Write ACE, and nowhere else those SIDs are concerned (the check ALSO * inherits the ambient write ACEs of the other restricting SIDs — the * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, @@ -60,7 +60,7 @@ import { directoryWriteSid } from './workspace-sid.ts' export { tempWriteSid, directoryWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' -/** Construction options: the workspace/temp allowlists and their distinct SID identities. */ +/** Construction options: the writable-directory and private-temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] @@ -74,7 +74,7 @@ export interface AclSandboxOptions { * The private temp directory's write SID. Required whenever * workspace-write grants a temp directory, absent otherwise. It must be * distinct from every directory write SID, so sibling sessions sharing a workspace - * cannot use the standing workspace capability in one another's temp tree. + * cannot use a standing directory capability in one another's temp tree. */ tempWriteSid?: string /** @@ -88,7 +88,7 @@ export interface AclSandboxOptions { /** * Whether this instance owns its DACL grants (default true). False means * the CALLER has already materialized the ACEs (the sandbox seam's - * workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — + * directory/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — * the caller holds the grants for its own lifetime and revokes them. */ manageDacls?: boolean @@ -148,13 +148,13 @@ function freeSidBestEffort( * 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 + * standing directory 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). */ + /** Canonical, deduplicated writable directories (constructor-validated). */ readonly writableDirs: readonly string[] /** The independently derived write SIDs, in the same order as writableDirs; empty under read-only. */ readonly writeSids: readonly string[] @@ -228,10 +228,8 @@ export class AclSandbox { if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) return parsedSid } - if (this.mode === 'workspace-write') { - for (const directory of this.writableDirs) { - this.directorySidPtrs.set(directory, parseSid(directoryWriteSid(directory))) - } + for (const [index, sid] of this.writeSids.entries()) { + this.directorySidPtrs.set(this.writableDirs[index] as string, parseSid(sid)) } this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) @@ -291,18 +289,18 @@ export class AclSandbox { // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every // piped-stdio grandchild spawn. Merge a full-access ACE for a // restricting SID (the PRIVATE temp SID when present, otherwise the - // workspace SID, or Everyone under read-only): new-object creation + // first directory SID, or Everyone when neither is present): new-object creation // stays gated by the parent object's DACL, while the new object's own // DACL passes pass-2. Choosing the temp SID prevents default-DACL // objects in one session's temp tree from acquiring the shared - // workspace capability. + // directory capability. setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.directorySidPtrs.values().next().value ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') currentTokenOpen = false this.api = api } catch (error) { // Fail-closed cleanup: never leave a revocable (temp) grant or SID - // allocation behind a failed init. Standing workspace ACEs are NOT + // allocation behind a failed init. Standing directory ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] @@ -397,7 +395,7 @@ export class AclSandbox { /** * Revoke the revocable (temp) grants, free the SID, close the token; the - * standing workspace ACEs stay (the reuse cache). Reports every cleanup + * standing directory ACEs stay (the reuse cache). Reports every cleanup * failure. */ dispose(): void { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 62cb1b40f3..a79aa33c32 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -4,7 +4,7 @@ * WRITE_RESTRICTED token with the per-directory 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). + * grant on exit (directory ACEs stay standing as the reuse cache). * * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 06ad9690f0..41e44dd322 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -101,7 +101,8 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { * (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 + * private-temp SID, otherwise the first directory SID, or Everyone when + * neither is present), 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. @@ -191,7 +192,7 @@ export interface RestrictingSidSet { * 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 that - * workspace grant inert under read-only while the unrevoked ACE keeps the + * directory grant inert under read-only while the unrevoked ACE keeps the * re-upgrade free (the grant's exact-ACE skip — no re-propagation). * Everyone's own ambient grants remain the documented partial boundary. * Authenticated Users is absent from BOTH lists: the WMI From 6d1156df70ee309d308b1acf96999a8044576143 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 29 Oct 2026 11:13:38 +0800 Subject: [PATCH 15/16] test(sandbox): cover directory SID default DACL selection --- .../sandbox-windows-acl/tests/acl.spec.ts | 13 +--------- .../tests/index-failure-paths.spec.ts | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index d1fa520dd3..fd97c0600c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -443,18 +443,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false) }) - it('workspace-write requires explicit temp authority and a nonempty token capability list', () => { - const dir = scratch() - expect(() => new AclSandbox({ writableDirs: [dir], mode: 'workspace-write' })) - .toThrow(/requires an explicit private temp directory or null/) - expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, mode: 'workspace-write' })) - .toThrow(/requires a temp write SID/) - expect(() => new AclSandbox({ - writableDirs: [dir], - tempDir: dir, - tempWriteSid: directoryWriteSid(dir), - mode: 'workspace-write', - })).toThrow(/must be distinct/) + it('workspace-write requires a nonempty token capability list', () => { expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) .toThrow(/requires at least one write SID/) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 2b39040490..43e9a1f3eb 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -223,6 +223,16 @@ describe('AclSandbox constructor validation', () => { .toThrow(/read-only does not accept write SIDs/u) }) + it('requires a distinct SID for private temp writes', () => { + const workspace = realpathSync.native(scratch()) + const temp = scratch() + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'workspace-write' })) + .toThrow(/requires a temp write SID/u) + expect(() => new AclSandbox({ + writableDirs: [workspace], tempDir: temp, tempWriteSid: directoryWriteSid(workspace), mode: 'workspace-write', + })).toThrow(/must be distinct/u) + }) + it('rejects a temp SID when temp writes are disabled', () => { const workspace = scratch() expect(() => new AclSandbox({ @@ -250,11 +260,11 @@ describe('AclSandbox init', () => { expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) }) - it('places every directory SID and the separate temp SID in the restricted token', async () => { + it.each([true, false])('places directory SIDs in the token and selects the default DACL (private temp: %s)', async (privateTemp) => { const { convertStringSidToSidW, createRestrictedToken, setEntriesInAclW } = state.stubs as HappyStubs const workspace = scratch() const memory = scratch() - const temp = scratch() + const temp = privateTemp ? scratch() : null const parsed: bigint[] = [] convertStringSidToSidW.mockImplementation((_sid: string, slot: NativePtr) => { const pointer = BigInt(101 + parsed.length) @@ -263,22 +273,24 @@ describe('AclSandbox init', () => { return 1 }) const sandbox = new AclSandbox({ - writableDirs: [workspace, memory], tempDir: temp, tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write', + writableDirs: [workspace, memory], tempDir: temp, mode: 'workspace-write', + ...(privateTemp ? { tempWriteSid: 'S-1-4-9000-1-1' } : {}), }) try { await sandbox.init() - const expectedSids = [...sandbox.writeSids, sandbox.tempWriteSid] + const expectedSids = [...sandbox.writeSids] + if (sandbox.tempWriteSid !== undefined) expectedSids.push(sandbox.tempWriteSid) expect(convertStringSidToSidW).toHaveBeenCalledTimes(expectedSids.length) expectedSids.forEach((sid, index) => { expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything()) }) const call = createRestrictedToken.mock.calls[0]! - expect(call[6]).toBe(5) + expect(call[6]).toBe(expectedSids.length + 2) const entries = call[7] as Buffer expect(parsed.map((_pointer, index) => entries.readBigUInt64LE((index + 2) * abi.SID_AND_ATTRIBUTES_SIZE))).toEqual(parsed) const defaultDaclMerge = setEntriesInAclW.mock.calls.find(call => call[2] === 88n)! const defaultEntry = defaultDaclMerge[1] as Buffer - expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[2]) + expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[privateTemp ? 2 : 0]) } finally { sandbox.dispose() } }) From 41a1aa8a972236bf67e579c46d502b96dd67b6f5 Mon Sep 17 00:00:00 2001 From: Yixiang Tu Date: Thu, 29 Oct 2026 12:23:39 +0800 Subject: [PATCH 16/16] fix(sandbox): limit Windows extra directory grants to file tools --- ...ssion-writable-directory-effects.i18n.yaml | 8 +- ...0-26-session-writable-directory-effects.md | 4 +- ...6-session-writable-directory-effects.zh.md | 4 +- ...ndows-directory-write-identities.i18n.yaml | 19 --- ...0-28-windows-directory-write-identities.md | 27 ---- ...8-windows-directory-write-identities.zh.md | 27 ---- ...ndows-file-tool-directory-grants.i18n.yaml | 19 +++ ...0-29-windows-file-tool-directory-grants.md | 25 ++++ ...9-windows-file-tool-directory-grants.zh.md | 25 ++++ ...ows-acl-restricted-token-sandbox.i18n.yaml | 20 +-- ...08-windows-acl-restricted-token-sandbox.md | 10 +- ...windows-acl-restricted-token-sandbox.zh.md | 10 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 8 +- docs/subsystems/sandbox.zh.md | 8 +- .../guide.i18n.yaml | 16 ++ .../sandbox-unsupported-policy-error/guide.md | 18 +++ .../guide.zh.md | 18 +++ .../windows-directory-sids/guide.i18n.yaml | 16 -- .../windows-directory-sids/guide.md | 20 --- .../windows-directory-sids/guide.zh.md | 20 --- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 11 +- .../sandbox/sandbox-local/README.i18n.yaml | 12 +- packages/sandbox/sandbox-local/README.md | 6 +- packages/sandbox/sandbox-local/README.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 106 ++++++++------ .../sandbox-local/tests/acl-grants.spec.ts | 138 +++--------------- .../sandbox/sandbox-local/tests/local.spec.ts | 11 +- .../sandbox/sandbox-policy/README.i18n.yaml | 8 +- packages/sandbox/sandbox-policy/README.md | 6 +- packages/sandbox/sandbox-policy/README.zh.md | 6 +- packages/sandbox/sandbox-policy/src/index.ts | 5 +- .../sandbox-policy/tests/policy.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 36 ++--- .../sandbox/sandbox-windows-acl/README.md | 41 +++--- .../sandbox/sandbox-windows-acl/README.zh.md | 41 +++--- .../sandbox/sandbox-windows-acl/src/grant.ts | 16 +- .../sandbox/sandbox-windows-acl/src/index.ts | 107 +++++++------- .../sandbox/sandbox-windows-acl/src/runner.ts | 55 +++---- .../sandbox/sandbox-windows-acl/src/token.ts | 9 +- .../sandbox-windows-acl/src/workspace-sid.ts | 38 +++-- .../sandbox-windows-acl/tests/acl.spec.ts | 63 ++++---- .../sandbox-windows-acl/tests/grant.spec.ts | 17 +-- .../tests/index-failure-paths.spec.ts | 127 ++++------------ .../sandbox-windows-acl/tests/probe.spec.ts | 7 +- .../sandbox-windows-acl/tests/runner.spec.ts | 98 ++----------- .../tests/workspace-sid.spec.ts | 29 ++-- packages/sandbox/sandbox/README.i18n.yaml | 8 +- packages/sandbox/sandbox/README.md | 4 +- packages/sandbox/sandbox/README.zh.md | 4 +- packages/sandbox/sandbox/src/index.ts | 5 +- packages/sandbox/sandbox/src/roots.ts | 6 +- .../session-sandbox-root/session.v4.jsonl | 2 +- 56 files changed, 569 insertions(+), 797 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md delete mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md create mode 100644 .agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md delete mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml delete mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md delete mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml index ea30576b82..d09004d689 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml @@ -9,11 +9,11 @@ en: 2a32db1f27371403 zh: 89cf04705ef66769 /agent-note-session-writable-directory-effects/decision: - en: 1ce0f8414a0f9db1 - zh: 0d2da6cf8ac2adbb + en: af67969841bedb67 + zh: edabdfaa30bcadd1 /agent-note-session-writable-directory-effects/alternatives-considered: en: dad77744dea17bde zh: bb3838f9f9099944 /agent-note-session-writable-directory-effects/consequences: - en: 008f7270b7d3aaaf - zh: 2b60ed4c4739ff75 + en: 1c2468df41bfbb46 + zh: 2118230c0091b20e diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md index edbd205025..2e6129df31 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md @@ -16,8 +16,6 @@ Registration disposal affects subsequent operations, not already-resolved calls Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules. -Windows standing ACL grants follow the [per-directory identity decision](2026-10-28-windows-directory-write-identities.md): each new execution carries only its currently permitted directory SIDs, while private temp capabilities remain separate. - ## Alternatives considered **Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service. @@ -28,4 +26,4 @@ Windows standing ACL grants follow the [per-directory identity decision](2026-10 ## Consequences -Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. +Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocesses ignore extra roots while file tools enforce them; the [Windows scope decision](2026-10-29-windows-file-tool-directory-grants.md) records this limitation. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md index 67d5c9bbf5..2e4c0a140d 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md @@ -16,8 +16,6 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周 不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。 -Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-directory-write-identities.zh.md):每次新执行仅携带当前允许的目录 SID,私有临时能力继续独立。 - ## 考虑过的替代方案 **共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。 @@ -28,4 +26,4 @@ Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-direct ## 影响 -同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 +同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程忽略额外根目录,文件工具则执行这些授权;[Windows 范围决策](2026-10-29-windows-file-tool-directory-grants.zh.md)记录此限制。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。 diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml deleted file mode 100644 index 1dcb2d8646..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-10-28-windows-directory-write-identities.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md -/agent-note-windows-per-directory-write-identities: - en: df2c97b00cabb275 - zh: e09a61baf823ff5e -/agent-note-windows-per-directory-write-identities/problem: - en: ce436e11d96c3c96 - zh: dcda6f85413a5be6 -/agent-note-windows-per-directory-write-identities/decision: - en: 2b778019f042b4c7 - zh: 6477f4b8a4d05cab -/agent-note-windows-per-directory-write-identities/alternatives-considered: - en: d87e2cf9196d66e7 - zh: 04261fa61efe407b -/agent-note-windows-per-directory-write-identities/consequences: - en: f20026b54351fcb4 - zh: 350ff60897c1f4bd diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md deleted file mode 100644 index 47bd25f0a2..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: Windows per-directory write identities - -Status: implemented - -English | [中文](2026-10-28-windows-directory-write-identities.zh.md) - -## Problem - -[Session-scoped writable roots](2026-10-26-session-writable-directory-effects.md) let independent plugins add directories to an execution's policy. A SID derived from the complete directory set requires additional standing ACEs and tree propagation on unchanged directories whenever that combination changes. - -## Decision - -Each canonical writable directory has one deterministic SID, granted only to that directory. Each new execution carries the SIDs for all its permitted directories. The provider caches grants by directory and rechecks their ACEs before execution so directory recreation does not invalidate the cache. Shared directories reuse the same standing grant across Sessions and permission combinations; a Session without the directory's SID receives no authority from that grant. - -`AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots. - -Directory grants compare supplied and physical paths with Node’s host-specific `path.relative`, while SID derivation retains the physical spelling. This accepts ordinary Windows casing differences without adding a component-walking implementation. The comparison assumes case-insensitive Windows paths; legacy 8.3 aliases remain unsupported. This deliberately favors the common path syntax over exhaustive filesystem-identity handling. - -## Alternatives considered - -**One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse. - -**One workspace SID extended to extra directories.** This accumulates authority for processes that already hold the workspace SID and for other Sessions using that workspace. Adding a directory must select another existing directory capability, not expand a shared capability's meaning. - -## Consequences - -Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The [Windows ACL decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) retains the restricted-token mechanism and hard-link limitations; this decision replaces its single-workspace identity model. diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md deleted file mode 100644 index d078334d53..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: Windows 逐目录写入身份 - -Status: implemented - -[English](2026-10-28-windows-directory-write-identities.md) | 中文 - -## 问题 - -[会话级可写根目录](2026-10-26-session-writable-directory-effects.zh.md)允许独立插件向执行策略添加目录。如果 SID 由完整目录集合派生,每次组合变化都需要向未变化的目录添加常驻 ACE,并重新传播整棵目录树。 - -## 决策 - -每个规范可写目录拥有一个确定性 SID,仅在该目录上授予它。每次新执行携带全部允许目录的 SID。提供方按目录缓存授权,并在执行前重新检查 ACE,避免目录重建使缓存失效。共享目录在不同 Session 和权限组合之间复用同一个常驻授权;未携带该目录 SID 的 Session 无法从该授权获得权限。 - -`AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。 - -目录授权使用 Node 按主机平台实现的 `path.relative` 比较传入路径与物理路径,SID 派生则保留物理路径的拼写。这接受普通 Windows 大小写差异,无需加入逐级遍历路径的实现。比较假定 Windows 路径不区分大小写;旧式 8.3 别名仍不受支持。这里有意优先支持常见路径语法,而不穷尽文件系统身份处理。 - -## 考虑过的替代方案 - -**每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。 - -**将工作区 SID 扩展到额外目录。** 这会为已经持有工作区 SID 的进程及使用该工作区的其他 Session 累积权限。添加目录必须选择另一个已有目录能力,而不能扩大共享能力的含义。 - -## 影响 - -已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。[Windows ACL 决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)保留受限令牌机制与硬链接限制;本决策替换其单工作区身份模型。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml new file mode 100644 index 0000000000..4b928edd3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-windows-file-tool-directory-grants.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md +/agent-note-windows-additional-directory-grants-stay-in-file-tools: + en: aea9858c74dfd36e + zh: 4064573838d6000f +/agent-note-windows-additional-directory-grants-stay-in-file-tools/problem: + en: da14bde7ed3eae49 + zh: 6aad2181b2756b50 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/decision: + en: acec40cda6036f2c + zh: 149c9eecb9863643 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/alternatives-considered: + en: 09d6cceef8004a65 + zh: e9009eb5bbae1d87 +/agent-note-windows-additional-directory-grants-stay-in-file-tools/consequences: + en: a37925f508bb5644 + zh: f5a6cb4e099f94c6 diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md new file mode 100644 index 0000000000..05b43edbfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md @@ -0,0 +1,25 @@ +# Agent Note: Windows additional directory grants stay in file tools + +Status: implemented + +English | [中文](2026-10-29-windows-file-tool-directory-grants.zh.md) + +## Problem + +Memory plugins need session-specific writes to directories such as `~/.dsh/memory`. Extending Windows process access through ACLs requires managing persistent filesystem grants and token identities for changing directory combinations, increasing reliance on a backend with existing enforcement limitations. + +## Decision + +On Windows, the [session directory effects](2026-10-26-session-writable-directory-effects.md) authorize extra directories only through the harness filesystem checks used by `write` and `edit`. The process provider ignores extra roots and retains its workspace and private-temp permissions. The ACL package, runner arguments, and workspace SID remain unchanged. POSIX process backends continue to honor extra roots. + +Windows process support for extra roots is deferred until a better sandbox backend can enforce it without extending the ACL customization. Model context explicitly distinguishes file-tool grants from Windows process permissions. + +## Alternatives considered + +**One SID per directory or directory set.** These designs could select process authority for each execution, but require more ACL mutation, identity management, and lifecycle handling. The capability does not justify expanding this mechanism. + +**Reject processes whenever extra grants exist.** This prevents unrelated PowerShell and workspace operations from running when a memory plugin registers a directory, even if the command never accesses it. + +## Consequences + +A Windows memory plugin can use `write` and `edit` for its registered directory while ordinary workspace commands continue to work. A shell command or PTC program cannot use that extra grant. This deliberately accepts different capabilities across file tools and process tools; registration and disposal remain session-scoped, and existing processes retain their launch-time permissions. diff --git a/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md new file mode 100644 index 0000000000..7d005c8597 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 额外目录授权仅用于文件工具 + +Status: implemented + +[English](2026-10-29-windows-file-tool-directory-grants.md) | 中文 + +## 问题 + +记忆插件需要针对会话向 `~/.dsh/memory` 等目录写入。通过 ACL 扩展 Windows 进程访问权限,需要为不断变化的目录组合管理持久文件系统授权与令牌身份,从而加深对已有执行限制的后端的依赖。 + +## 决策 + +在 Windows 上,[会话目录 effect](2026-10-26-session-writable-directory-effects.zh.md) 仅通过 `write` 与 `edit` 使用的 Harness 文件系统检查授权额外目录。进程提供方忽略额外根目录,保留工作区与私有临时目录权限。ACL 包、runner 参数与工作区 SID 保持不变。POSIX 进程后端继续执行额外根目录授权。 + +Windows 进程的额外根目录支持推迟到更好的沙箱后端能够实现该功能、无需扩展 ACL 定制时。模型上下文明示文件工具授权与 Windows 进程权限的区别。 + +## 考虑过的替代方案 + +**为每个目录或目录集合分配 SID。** 这些设计可以逐次执行选择进程权限,但需要更多 ACL 修改、身份管理与生命周期处理。该能力不足以证明扩展此机制的必要性。 + +**存在额外授权时拒绝进程。** 当记忆插件注册目录时,这会阻止无关的 PowerShell 和工作区操作运行,即使命令从不访问该目录。 + +## 影响 + +Windows 记忆插件可以通过 `write` 与 `edit` 访问注册的目录,同时普通工作区命令继续运行。shell 命令或 PTC 程序无法使用该额外授权。这明确接受文件工具与进程工具的能力差异;注册和清理仍限定于会话,已有进程保留启动时的权限。 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 ed74eb3498..cd33be628c 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 @@ -9,14 +9,14 @@ en: f62d87b7623a9904 zh: 48bbb7c35298f96c /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision: - en: 90fd9a7cf5e25d03 - zh: 916b1eac07da4eb6 + en: 10e9cfb7972cff03 + zh: 8406ba64d72a1fcc /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery: en: a5bcdf2a6133abfe zh: 477d4915d61945e3 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity: - en: 3bc4bd8f6d4588c8 - zh: e4760533aa152a5d + en: 1ba89a61a733b371 + zh: 4fa134ad1b2059a1 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered: en: 6119e2aff3b6be5a zh: 4237c596c10e1ee9 @@ -30,14 +30,14 @@ en: e2c573d90c4ddeb9 zh: f0259213e1321722 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences: - en: 883c393d20d42da3 - zh: 541fdf737114b7ae + en: dd43744417f207a5 + zh: d7b4fee1e2d87b60 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing: - en: 2175317fcb25a52d - zh: 076cee14f261f85e + en: b223cb2cf13193c9 + zh: 61ddd0a0b99f8f56 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related: - en: da294868c980f138 - zh: 8700a7146483b0b8 + en: dd16f0c5815df991 + zh: df3c1cf511ee52bf /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped: en: 5262178f0035b215 zh: 10505c394ea33ee3 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 78297a2d41..6cda3f1970 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 original [historical sandbox decision](../../archived/feature/2026-07-06-san ## 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` is not used) whose restricting SIDs carry distinct directory and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire a shared directory capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A writable root equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, ...directory SIDs, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing directory ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (otherwise the first directory SID, or Everyone when neither is present) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +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` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. 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 ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## ACL diagnosis and recovery @@ -22,7 +22,7 @@ Both extracted and recovery scripts are user-writable, so this workflow never re ## 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 capability-SID check, which only the permitted-directory and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. 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. +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 capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. 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 @@ -40,16 +40,14 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the standing capabilities for their permitted directories but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing directory security-descriptor mutations (the reuse cache, plus old ACEs when a directory is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree directory propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, per-directory and temp argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins directory/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, directory/temp SID validation, sibling temp denial under shared directory SIDs, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related -The [per-directory identity decision](../architecture/2026-10-28-windows-directory-write-identities.md) replaces the single workspace capability with independently reusable directory capabilities; this note retains the restricted-token mechanism and its limitations. - The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. 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 38303084b9..1f2848d31c 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 ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的目录能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的目录能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果可写根目录等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、全部目录 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻目录 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(否则继承第一个目录 SID,两者均不存在时继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 ## ACL 诊断与恢复 @@ -23,7 +23,7 @@ Status: implemented ## 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` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(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` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 ## 考虑过的替代方案 @@ -41,16 +41,14 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享其获准目录的常驻能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);目录常驻安全描述符改动(复用缓存,以及目录改名后的旧 ACE)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;目录授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、逐目录与临时目录 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住目录/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、目录/临时目录 SID 验证、共享目录 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related -[逐目录身份决策](../architecture/2026-10-28-windows-directory-write-identities.zh.md)将单一工作区能力替换为可独立复用的目录能力;本笔记保留受限令牌机制及其限制。 - [pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 [删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dcd5781374..377354f917 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2926,7 +2926,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -2986,7 +2986,7 @@ export interface Config { * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under - * `read-only`. + * `read-only`. Windows process confinement ignores them; harness file tools honor them. */ additionalWritableRoots?: string[] } diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 428de8d3aa..5644aa25f6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2928,7 +2928,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -2988,7 +2988,7 @@ export interface Config { * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under - * `read-only`. + * `read-only`. Windows process confinement ignores them; harness file tools honor them. */ additionalWritableRoots?: string[] } diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 05366c3c58..3d2954b1c4 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -6,8 +6,8 @@ en: e0d4876306b192c3 zh: 63baaa0c79e87deb /process-sandbox/modes-and-enforcement: - en: 6777e1b1270ec3e1 - zh: 22533fb3304c1dd8 + en: 3e518b62c9bbcd6e + zh: d59d4a3830ce7cf6 /process-sandbox/per-call-policy: en: 53850780ce70e3d8 zh: 60769c319d778e0e diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index f52a93e51a..c530276e90 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -8,13 +8,13 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, explicit extra directories, 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` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, extra directories where supported, and the backend's promised temp area; `danger-full-access` bypasses confinement. Windows subprocesses ignore extra directory grants; the harness file tools enforce them independently. Network and process visibility are outside this vocabulary. ```ts type-equiv /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -57,6 +57,7 @@ interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -64,7 +65,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by individual writable directories); absent + * while the workspace SID and standing grant remain per-workspace); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId @@ -221,6 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * registration. Other grants to the directory remain effective. Existing * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. + * On Windows, only harness file tools honor extra grants; process permissions stay unchanged. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 10fd3ad925..c3cfd0720e 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -8,13 +8,13 @@ ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、显式额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、受支持的额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。Windows 子进程忽略额外目录授权;Harness 文件工具独立执行这些授权。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -57,6 +57,7 @@ interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -64,7 +65,7 @@ interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by individual writable directories); absent + * while the workspace SID and standing grant remain per-workspace); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId @@ -221,6 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy * registration. Other grants to the directory remain effective. Existing * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. + * On Windows, only harness file tools honor extra grants; process permissions stay unchanged. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml new file mode 100644 index 0000000000..5cba8520c1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md +/: + en: d7c1fc703991b92a + zh: f5cddebaa6b311a0 +/remove-sandboxunsupportedpolicyerror-imports: + en: fc8fa3704fa036ce + zh: e96b09c9882f760b +/remove-sandboxunsupportedpolicyerror-imports/change: + en: 3ca1bb8d19b7781e + zh: 9f3800c847791344 +/remove-sandboxunsupportedpolicyerror-imports/migration: + en: 29b8d3231a83e606 + zh: 24416c92a4dc9b71 diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md new file mode 100644 index 0000000000..35605b3804 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The sandbox package removes SandboxUnsupportedPolicyError; Windows processes ignore additional writable roots." +--- + +# Remove SandboxUnsupportedPolicyError imports + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-sandbox` no longer exports `SandboxUnsupportedPolicyError`. Windows subprocess confinement ignores additional writable directories instead of rejecting execution with `SANDBOX_UNSUPPORTED_POLICY`. The harness `write` and `edit` tools still honor those directories; Windows processes retain workspace and private-temp access under `workspace-write`. + +## Migration + +1. Remove imports and error handlers specific to `SandboxUnsupportedPolicyError` or `SANDBOX_UNSUPPORTED_POLICY`. +2. Use the harness file tools for writes to additional directories on Windows. Do not assume a successful process launch grants those directories. +3. Rebuild the consumer and verify that workspace commands run while extra directories remain accessible only through the file tools. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md new file mode 100644 index 0000000000..4413d95e11 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "sandbox 包移除 SandboxUnsupportedPolicyError;Windows 进程忽略额外可写根目录。" +--- + +# 移除 SandboxUnsupportedPolicyError 导入 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-sandbox` 不再导出 `SandboxUnsupportedPolicyError`。Windows 子进程隔离忽略额外可写目录,不再以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝执行。Harness 的 `write` 与 `edit` 工具仍执行这些目录的授权;Windows 进程在 `workspace-write` 下保留工作区与私有临时目录访问权限。 + +## 迁移 + +1. 移除针对 `SandboxUnsupportedPolicyError` 或 `SANDBOX_UNSUPPORTED_POLICY` 的导入与错误处理。 +2. 在 Windows 上使用 Harness 文件工具写入额外目录。不要假定进程启动成功就意味着这些目录已获授权。 +3. 重新构建消费方,验证工作区命令可以运行,额外目录仍仅能通过文件工具访问。 diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml deleted file mode 100644 index 71ce04f47f..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md -/: - en: dbd8a58fb86d9bd4 - zh: 1e8f6661b7c9122d -/windows-acl-directory-identities: - en: fd8ac37f696dac64 - zh: b5531e3c276b0c88 -/windows-acl-directory-identities/change: - en: 2893ccdcc064e754 - zh: 5ced21fcad53bbaf -/windows-acl-directory-identities/migration: - en: f3512bba3f54f294 - zh: 98f2bc1de08c0c8b diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md deleted file mode 100644 index 79c3264396..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -kind: upgrade-guide -description: "Windows ACL callers use derived per-directory SIDs instead of supplying one shared write SID." ---- - -# Windows ACL directory identities - -English | [中文](guide.zh.md) - -## Change - -`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. `writableDirs` is now readonly and contains canonical, deduplicated paths. Windows accepts valid additional writable directories, and `@deepseek-ai/dsh-sandbox` removes `SandboxUnsupportedPolicyError` and its `SANDBOX_UNSUPPORTED_POLICY` code. Users of the standard `dsh` profiles need no configuration changes. - -## Migration - -1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. Do not mutate `writableDirs`; construct a new instance for a different directory list. Compare its entries with canonical paths rather than the original input spellings. -2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories. -3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag. -4. Remove imports and catches for `SandboxUnsupportedPolicyError` and checks for `SANDBOX_UNSUPPORTED_POLICY`; Windows supports valid extra directories instead of rejecting that policy. -5. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions. diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md deleted file mode 100644 index 134debe76b..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -kind: upgrade-guide -description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供一个共享的写入 SID。" ---- - -# Windows ACL 目录身份 - -[English](guide.md) | 中文 - -## 变更 - -`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。`writableDirs` 现为只读,并包含规范化、去重后的路径。Windows 接受有效的额外可写目录,`@deepseek-ai/dsh-sandbox` 移除 `SandboxUnsupportedPolicyError` 及其 `SANDBOX_UNSUPPORTED_POLICY` 错误码。标准 `dsh` profile 的用户无需修改配置。 - -## 迁移 - -1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。不要修改 `writableDirs`;目录列表变化时创建新实例。将其条目与规范路径比较,而非原始输入拼写。 -2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。 -3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。 -4. 移除 `SandboxUnsupportedPolicyError` 的导入与捕获,以及对 `SANDBOX_UNSUPPORTED_POLICY` 的检查;Windows 支持有效的额外目录,不再拒绝该策略。 -5. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5c112c83cf..0ea06dd04f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2585,7 +2585,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'registerWritableRoot(session: Session, directory: string): () => void', - description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.', + description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork. On Windows, only harness file tools honor extra grants; process permissions stay unchanged.', parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.' }], returns: 'the exact Cordis effect disposer for this registration.', throws: ['when the directory is relative or contains a NUL byte.'], diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index fa9b48d873..389911b0c8 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -92,7 +92,7 @@ describe('read-only', () => { describe('workspace-write containment', () => { beforeEach(() => boot('workspace-write')) - it('applies session grants to later writes and removes them when their plugin unloads', async ({ onTestFinished }) => { + it('applies session grants to writes and edits until their plugin unloads', async ({ onTestFinished }) => { onTestFinished(() => ctx.fiber.dispose()) const first = Session.create(SessionId('memory-first')) const second = Session.create(SessionId('memory-second')) @@ -100,6 +100,10 @@ describe('workspace-write containment', () => { const write = async (session: Session, content: string) => fs.writeText( await target(path), content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }), ) + const edit = async (session: Session) => fs.editText( + await target(path), { oldString: 'memory', newString: 'edited', replaceAll: false }, + undefined, undefined, ctx.sandboxPolicy.resolve({ session }), + ) await expect(write(first, 'denied')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) const plugin = await ctx.plugin({ inject: ['sandboxPolicy'], @@ -108,12 +112,17 @@ describe('workspace-write containment', () => { await write(first, 'memory') expect(await readFile(path, 'utf8')).toBe('memory') await expect(write(second, 'other session')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + await expect(edit(second)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + await edit(first) + expect(await readFile(path, 'utf8')).toBe('edited') setSandboxMode(first, 'read-only') await expect(write(first, 'read-only')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + await expect(edit(first)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) setSandboxMode(first, 'workspace-write') await write(first, 'updated') await plugin.dispose() await expect(write(first, 'revoked')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + await expect(edit(first)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) expect(await readFile(path, 'utf8')).toBe('updated') }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 69844719dd..b660d61a20 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7282c9ec77fd6f01 zh: 10e385a9489105e3 /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery: - en: f7033dc3f9c073de - zh: e667973ac2e40ad0 + en: b1f9aeca3baf9561 + zh: a5681f4f49259468 /deepseek-ai-dsh-sandbox-local/understand-the-implementation: en: 15db6ec303151ccc zh: 91f62c1f64192f92 @@ -36,8 +36,8 @@ en: f0c44248c2ca6acc zh: 554e9f706671d222 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles: - en: c167bc9f232d1103 - zh: a2172af886eff752 + en: 008967ee30fbbe46 + zh: 9e5c6a3e97851a69 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects: en: 62adfc9229c656cc zh: 11b1534b6d61e5de @@ -54,8 +54,8 @@ en: e290ccc9b3c92c08 zh: 469589ac12cdadab /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work: - en: 71f863f5ee97d9ba - zh: cabc01953e6cb94d + en: 08b89c59db310961 + zh: b505db4148754f94 /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 481829b0d2..578caa7c63 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly. -Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. +Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only. ----- @@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). -The Windows rung derives one deterministic write SID per canonical writable directory and reuses its standing ACE across permission combinations, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources. @@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. -- **Windows temp separation** — writable roots cannot contain the ambient temp root or overlap the session's private temp directory. Those overlaps would expose private temp files through a shared directory capability. +- **Windows extra roots** — Additional directory grants apply to the harness `write` and `edit` tools only. PowerShell, terminals, PTC, and other sandboxed processes retain workspace and private-temp access under `workspace-write`. Extra grants do not change their ACLs or block their execution; see the [decision](../../../.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md). - **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 65d301dfd0..7787decc5b 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。 -Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。 +Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。 ----- @@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档为每个规范可写目录派生一个确定性写入 SID,并在不同权限组合之间复用其常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。 @@ -130,7 +130,7 @@ Windows 档为每个规范可写目录派生一个确定性写入 SID,并在 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows 临时目录隔离**——可写根目录不能包含系统临时根目录,也不能与会话的私有临时目录重叠。这类重叠会通过共享目录能力暴露私有临时文件。 +- **Windows 额外根目录**——额外目录授权仅适用于 Harness 的 `write` 与 `edit` 工具。PowerShell、终端、PTC 及其他受沙箱限制的进程在 `workspace-write` 下保留工作区与私有临时目录访问权限。额外授权不修改其 ACL,也不阻塞其执行;参见[决策](../../../.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md)。 - **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 18acb7cbd4..52cea43d23 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,13 +5,19 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung owns standing grants keyed by canonical directory. - * Each directory has a deterministic write SID, and an execution carries only - * the directory SIDs its current policy permits. Every live session/workspace - * pair has a separate random private temp directory and revocable temp SID. - * The runner receives both identities and does not manage their DACLs. - * Enforcement remains partial because hard links alias file objects, reads - * stay unconfined, and AppContainer ACLs can deny reads to the child. + * The windows-acl rung additionally owns the write grants: the write SID is + * the per-WORKSPACE identity derived from the canonical workspace path + * (`workspaceWriteSid`), while every live session receives a RANDOM private + * temp directory and its own derived capability (`tempWriteSid`). 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 both SIDs (their presence marks the seam-managed contract) and + * stops managing DACLs itself. The rung reports partial enforcement because + * NTFS hard links alias one file object across paths, reads stay unconfined, + * and a tree another AppContainer tool has ACL'd with a package SID is not + * readable by the Low-integrity child. * @module @deepseek-ai/dsh-sandbox-local */ @@ -28,10 +34,10 @@ import { } from '@deepseek-ai/node-addon-system/landlock-run' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox' +import { SandboxProvider, SandboxUnavailableError, canonicalPath } 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, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -238,7 +244,7 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants - * ({@link AclWriteGrant}: standing grants per writable directory + * ({@link AclWriteGrant}: the standing workspace-root grant per workspace * and the revocable private-temp grant per live session/workspace pair, the * latter revoked on provider dispose); the one-time probes spawn nothing * else. @@ -259,9 +265,14 @@ 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 - /** Standing grants keyed by canonical directory; ACEs outlive the provider. */ - private readonly directoryGrants = new Map() - /** Revocable private temp grants per live session/workspace pair. */ + /** + * 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 live session/workspace pair (revoked on provider + * dispose). + */ + private readonly workspaceGrants = new Map() private readonly tempCapabilities = new Map() constructor(ctx: Context, config: Config) { @@ -344,15 +355,20 @@ export class LocalSandboxProvider extends SandboxProvider { } /** - * Build the Windows runner invocation for the current writable-directory set. - * Session calls reuse standing directory grants and a private temp capability. - * Agentless calls let the runner materialize the set and own its private temp. + * The windows-acl runner argv for one policy. With a calling session (the + * policy's `sessionId`) under workspace-write, the grants are materialized + * once per provider lifetime — the standing workspace-root grant per + * workspace and a revocable, RANDOM private-temp capability per live + * session/workspace pair. The runner receives `--write-sid` plus + * `--temp-write-sid` and grants nothing itself. Agentless workspace-write + * calls pass the ambient temp ROOT and no SID flags: the runner creates and + * removes a random private child directory for that one invocation. + * Additional directory grants apply only to harness filesystem operations + * on Windows; process confinement keeps the workspace and private temp grants. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { - const roots = workspaceWritableRoots(policy) - const extraArgs = roots.slice(1).flatMap(root => ['--writable-root', root]) const sessionId = policy.sessionId if (sessionId === undefined || policy.mode === 'read-only') { return [ @@ -360,54 +376,54 @@ export class LocalSandboxProvider extends SandboxProvider { '--workspace', policy.workspaceRoot, '--temp', tmpdir(), '--mode', policy.mode, - ...extraArgs, ] } - const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot, roots) + const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, '--temp', temp.dir, '--mode', policy.mode, - ...extraArgs, - ...roots.flatMap(root => ['--write-sid', directoryWriteSid(root)]), + '--write-sid', workspaceWriteSid(policy.workspaceRoot), '--temp-write-sid', temp.writeSid, ] } /** - * Resolve or materialize each permitted directory's standing ACE. - * Private temp capabilities remain scoped to the session/workspace pair; - * changing directory sets does not revoke grants held by running processes. - * Failed temp materialization revokes its grant and removes its directory. + * Materialize one workspace-write policy's ACEs once per provider + * lifetime. The workspace SID and standing root grant are shared by the + * workspace. The temp directory is random and carries a distinct SID, so + * another session on the same workspace cannot use the shared workspace + * SID to enter it. A fresh provider always chooses a new path; crash + * residue therefore cannot collide with or authorize a resumed session. + * Fail-closed: a half-materialized temp grant is revoked and its directory + * removed before the error propagates. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. - * @param roots - the complete canonical writable-directory set for this execution. * @returns the pair's private temp directory and write capability. */ - private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, roots: readonly string[]): AclTempCapability { - for (const root of roots) assertTempRootOutsideWorkspace(root, tmpdir()) - const key = JSON.stringify([String(sessionId), workspaceRoot]) - const existing = this.tempCapabilities.get(key) - if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir) - for (const root of roots) { - const cachedGrant = this.directoryGrants.get(root) - const directoryGrant = cachedGrant ?? AclWriteGrant.create(directoryWriteSid(root)) + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { + assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) + const writeSid = workspaceWriteSid(workspaceRoot) + if (!this.workspaceGrants.has(workspaceRoot)) { + const grant = AclWriteGrant.create(writeSid) try { - directoryGrant.add(root, true) + grant.add(workspaceRoot, true) } catch (error) { - if (cachedGrant !== undefined) throw error - // Standing ACEs remain reusable; only the failed entry's allocations - // are released. A later execution retries this directory's grant. + // 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 { - directoryGrant.dispose() + grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed') + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') } throw error } - this.directoryGrants.set(root, directoryGrant) + this.workspaceGrants.set(workspaceRoot, grant) } + const key = JSON.stringify([String(sessionId), workspaceRoot]) + const existing = this.tempCapabilities.get(key) if (existing !== undefined) return existing const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) const tempSid = tempWriteSid(tempDir) @@ -449,9 +465,9 @@ export class LocalSandboxProvider extends SandboxProvider { * OS temp hygiene (or manual removal) eventually reclaims it. */ private revokeAclGrants(): void { - if (this.directoryGrants.size === 0 && this.tempCapabilities.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return const failures: unknown[] = [] - for (const grant of [...this.directoryGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { + for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { try { grant.dispose() } catch (error) { @@ -465,7 +481,7 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } - this.directoryGrants.clear() + this.workspaceGrants.clear() this.tempCapabilities.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 6ed6d2a7d3..7f69bc3ddf 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,11 +1,11 @@ /** * windows-acl grant ownership through the real LocalSandboxProvider: one - * standing capability per directory plus one random, distinct, revocable + * standing capability per workspace plus one random, distinct, revocable * temp capability per live session/workspace pair. The Win32 grant surface * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,21 +13,18 @@ import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' -import { directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' /** 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, - addFailurePath: undefined as string | undefined, /** Restrict an add failure to standing (workspace) or revocable (temp). */ addFailureStanding: undefined as boolean | undefined, createTempFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) -vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { class MockAclWriteGrant { readonly writeSid: string readonly added: Array<{ path: string; standing: boolean }> = [] @@ -43,7 +40,6 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => { add(path: string, standing = false): void { this.added.push({ path, standing }) if (mockState.addFailure !== undefined - && (mockState.addFailurePath === undefined || mockState.addFailurePath === path) && (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) { throw mockState.addFailure } @@ -54,7 +50,6 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => { } } return { - ...actual, registerAclDiagnosisSkill: vi.fn(), AclWriteGrant: MockAclWriteGrant, assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { @@ -64,10 +59,13 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => { throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) } }, + workspaceWriteSid: () => 'S-1-4-42-42', tempWriteSid: (path: string) => `TEMP:${path}`, } }) +const WORKSPACE_SID = 'S-1-4-42-42' + async function setup() { const ctx = new Context() const fiber = await ctx.plugin(LocalSandboxProvider, {}) @@ -85,17 +83,12 @@ function flag(argv: readonly string[], name: string): string | undefined { return index < 0 ? undefined : argv[index + 1] } -function flags(argv: readonly string[], name: string): string[] { - return argv.filter((_value, index) => index > 0 && argv[index - 1] === name) -} - describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined - mockState.addFailurePath = undefined mockState.addFailureStanding = undefined mockState.createTempFailure = undefined mockState.disposeFailure = undefined @@ -136,25 +129,30 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(tempDir).toBeDefined() expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) expect(tempSid).toBe(`TEMP:${tempDir}`) - expect(tempSid).not.toBe(directoryWriteSid(ws)) + expect(tempSid).not.toBe(WORKSPACE_SID) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', directoryWriteSid(ws), + '--write-sid', WORKSPACE_SID, '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toEqual([ - expect.objectContaining({ writeSid: directoryWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }), expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), ]) expect(existsSync(tempDir ?? '')).toBe(true) - expect((await sandbox.confine(['pwsh', '/Command', 'x'], policy)).argv).toEqual(confined.argv) + const extra = workspaceRoot() + scratch.push(extra) + expect((await sandbox.confine(['pwsh', '/Command', 'x'], { + ...policy, additionalWritableRoots: [extra], + })).argv).toEqual(confined.argv) expect(mockState.grants).toHaveLength(2) + expect(mockState.grants.flatMap(grant => grant.added.map(added => added.path))).toEqual([ws, tempDir]) await fiber.dispose() expect(mockState.grants.every(grant => grant.disposed)).toBe(true) @@ -164,108 +162,6 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('reuses each directory grant across changing sets and workspaces without granting sibling roots', async () => { - const { sandbox, fiber } = await setup() - try { - const ws = workspaceRoot() - const memory = workspaceRoot() - const cache = workspaceRoot() - const otherWorkspace = workspaceRoot() - scratch.push(ws, memory, cache, otherWorkspace) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') } - const base = await sandbox.confine(['true'], policy) - const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] }) - expect(flags(expanded.argv, '--write-sid')).toEqual([ws, memory, cache].map(directoryWriteSid)) - expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp')) - expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid')) - expect(mockState.grants).toHaveLength(4) - const memoryGrant = mockState.grants.find(grant => grant.writeSid === directoryWriteSid(memory))! - const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] }) - expect(flags(reordered.argv, '--write-sid')).toEqual([ws, cache, memory].map(directoryWriteSid)) - expect(mockState.grants).toHaveLength(4) - expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv) - expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) - - const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') }) - expect(flags(sibling.argv, '--write-sid')).toEqual([directoryWriteSid(ws)]) - expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid')) - const other = await sandbox.confine(['true'], { - ...policy, sessionId: SessionId('other'), workspaceRoot: otherWorkspace, additionalWritableRoots: [memory], - }) - expect(flags(other.argv, '--write-sid')).toEqual([otherWorkspace, memory].map(directoryWriteSid)) - expect(mockState.grants).toHaveLength(7) - expect(mockState.grants.filter(grant => grant.writeSid === directoryWriteSid(memory))).toEqual([memoryGrant]) - for (const root of [ws, memory, cache, otherWorkspace]) { - const grant = mockState.grants.find(candidate => candidate.writeSid === directoryWriteSid(root))! - expect(grant.added.every(added => added.path === root && added.standing)).toBe(true) - } - } finally { await fiber.dispose(); cleanup() } - }) - - it('reapplies a cached directory grant when an extra directory is recreated', async () => { - const { sandbox, fiber } = await setup() - try { - const ws = workspaceRoot() - const extra = workspaceRoot() - scratch.push(ws, extra) - const policy: SandboxPolicy = { - mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra], - } - const first = await sandbox.confine(['true'], policy) - const directoryGrant = mockState.grants[1]! - rmSync(extra, { recursive: true }) - mkdirSync(extra) - const second = await sandbox.confine(['true'], policy) - expect(second.argv).toEqual(first.argv) - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[1]).toBe(directoryGrant) - expect(directoryGrant.added).toEqual([extra, extra].map(path => ({ path, standing: true }))) - } finally { await fiber.dispose(); cleanup() } - }) - - it('retains successful directory grants and retries the directory that failed', async () => { - const { sandbox, fiber } = await setup() - try { - const ws = workspaceRoot() - const extra = workspaceRoot() - scratch.push(ws, extra) - const policy: SandboxPolicy = { - mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('set-retry'), additionalWritableRoots: [extra], - } - mockState.addFailure = new Error('extra grant failed') - mockState.addFailurePath = extra - await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed') - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: true }) - mockState.addFailure = undefined - await sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(4) - expect(mockState.grants[2]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: false }) - mockState.addFailure = new Error('cached grant refresh failed') - await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed') - expect(mockState.grants).toHaveLength(4) - expect(mockState.grants[2]!.disposed).toBe(false) - mockState.addFailure = undefined - await sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(4) - } finally { await fiber.dispose(); cleanup() } - }) - - it('refuses a changed set overlapping its private temp before adding standing grants', async () => { - const { sandbox, fiber } = await setup() - try { - const ws = workspaceRoot() - scratch.push(ws) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-overlap') } - const confined = await sandbox.confine(['true'], policy) - const temp = realpathSync.native(flag(confined.argv, '--temp')!) - await expect(sandbox.confine(['true'], { ...policy, additionalWritableRoots: [temp] })) - .rejects.toThrow('private temp directory must be disjoint from writable directories') - expect(mockState.grants).toHaveLength(2) - } finally { await fiber.dispose(); cleanup() } - }) - it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => { try { const { sandbox, fiber } = await setup() @@ -285,7 +181,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(0) const upgraded = await sandbox.confine(['true'], workspaceWrite) - expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(directoryWriteSid(ws)) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID) expect(mockState.grants).toHaveLength(2) await sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) @@ -361,7 +257,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.disposeFailure = new Error('workspace cleanup exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), - })).rejects.toThrow(/directory grant failed and its cleanup also failed/u) + })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u) expect(mockState.grants).toHaveLength(2) } finally { cleanup() diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 928578851e..7da5f02415 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -430,7 +430,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { }) describe('the windows-acl probe (runner invocation contract)', () => { - it('passes existing extra directories to the Windows runner and skips non-directories', async () => { + it.each([RO, WW])('ignores extra directory grants in $mode runner arguments', async (policy) => { const { ctx, sandbox } = await setup({}, { chain: ['windows-acl'], probeWindowsAcl: () => true, @@ -441,11 +441,10 @@ describe('the windows-acl probe (runner invocation contract)', () => { const file = join(extra, 'file.txt') writeFileSync(file, 'file') try { - await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory') - const confined = await sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }) - expect(confined.argv).toContain('--writable-root') - expect(confined.argv[confined.argv.indexOf('--writable-root') + 1]).toBe(extra) - await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined() + const original = await sandbox.confine(['true'], policy) + await expect(sandbox.confine(['true'], { + ...policy, additionalWritableRoots: [extra, join(extra, 'missing'), file], + })).resolves.toEqual(original) } finally { await ctx.fiber.dispose() } diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 61c94dc100..a5cc96a557 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,11 +21,11 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: c7e924a923bfac72 - zh: fdaf930a5800ffa6 + en: 0616478ef5bb0566 + zh: e3a5802aefcf8424 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: - en: 28587491ad2ac66a - zh: c5d4dc441e3f0459 + en: 841ea47ef1ed7ee6 + zh: cd573e67a67742e2 /deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode: en: 957d72691e470e5c zh: c22a3a750af0e4a3 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 1a4e672fdc..022ba841e4 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -46,7 +46,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and |---|---|---| | `mode` | `read-only` | The deployment default mode a session starts from, validated at load | | `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd | -| `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them | +| `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access | For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. @@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply. -Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. +Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, `write` and `edit` honor extra directories, while sandboxed processes ignore them and keep workspace and private-temp access; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work). ### Switching a session's mode @@ -134,7 +134,7 @@ Current DSH file policy: workspace-write. Any available operation enforced by th ##### Workspace-write with extra directories ```markdown -Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "". Additional writable directories: ["/repo/.git"]. Some platform temporary areas may also be writable. +Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "". Additional writable directories: ["/repo/.git"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable. ``` ##### Danger-full-access diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 377b6db5bd..49d976c7bf 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" |---|---|---| | `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 | | `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd | -| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 | +| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 | 对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 @@ -56,7 +56,7 @@ kind: "package-reference" 插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。 -注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。 +注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,`write` 与 `edit` 执行额外目录授权,受沙箱限制的进程则忽略这些授权,保留工作区与私有临时目录访问权限;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。 ### 切换会话模式 @@ -134,7 +134,7 @@ Current DSH file policy: workspace-write. Any available operation enforced by th ##### 带额外目录的工作区写入 ```markdown -Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "". Additional writable directories: ["/repo/.git"]. Some platform temporary areas may also be writable. +Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "". Additional writable directories: ["/repo/.git"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable. ``` ##### 完全访问 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 064c14b070..2136e33f45 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -66,7 +66,7 @@ function renderPolicyContext(policy: SandboxExecutionPolicy): string { const additional = policy.additionalWritableRoots const extra = additional === undefined ? '' - : ` Additional writable directories: ${JSON.stringify(additional)}.` + : ` Additional writable directories: ${JSON.stringify(additional)}. On Windows, these additional grants apply to file tools only, not sandboxed processes.` return `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(policy.workspaceRoot)}.${extra} Some platform temporary areas may also be writable.` } case 'danger-full-access': @@ -106,7 +106,7 @@ export interface Config { * automatic repository or gitfile discovery. Entries are execution-world * paths: this service validates absoluteness and detaches the list, while * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under - * `read-only`. + * `read-only`. Windows process confinement ignores them; harness file tools honor them. */ additionalWritableRoots?: string[] } @@ -218,6 +218,7 @@ export class SandboxPolicyService extends Service { * registration. Other grants to the directory remain effective. Existing * operations and persistent processes retain their resolved permissions. * Grants are runtime-only and must be registered again after resume or fork. + * On Windows, only harness file tools honor extra grants; process permissions stay unchanged. * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected. * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths. * @returns the exact Cordis effect disposer for this registration. diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 2de5d84eb2..1d4588f49e 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -288,7 +288,7 @@ describe('sandbox:policy request context', () => { additionalWritableRoots: ['/projects/shared/.git', '/projects/shared/worktrees'], }) expect(await policyContext(ctx, session('sess-extra', '/projects/current'))).toBe( - `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify('/projects/current')}. Additional writable directories: ["/projects/shared/.git","/projects/shared/worktrees"]. Some platform temporary areas may also be writable.`, + `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify('/projects/current')}. Additional writable directories: ["/projects/shared/.git","/projects/shared/worktrees"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.`, ) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 5ae1a26af5..94a3e37675 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -9,8 +9,8 @@ en: e7cdbd2652fe80ab zh: 04e1d187313a0bd5 /deepseek-ai-dsh-sandbox-windows-acl/summary: - en: dac35dbc120ce3ec - zh: b46a5c0d31af6f1f + en: 777921135c23ee55 + zh: 7c726cc63995d443 /deepseek-ai-dsh-sandbox-windows-acl/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -21,11 +21,11 @@ en: 510d0b1ef7db159d zh: dc2cf25b3db14c63 /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/direct-api: - en: 0208dc12f0be12ea - zh: 3ce871ac35bd1828 + en: 16596607bbccc2a3 + zh: 30bc370a5281bf0d /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you: - en: 0ae33482d28f637b - zh: a2b87b18737a6d07 + en: 289e65bddf20dd79 + zh: 7ff72ed64c4b4d1c /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: en: 7fc0707debb1e67c zh: e3b5529bf82f868e @@ -33,17 +33,17 @@ en: c2cde86c85c2c619 zh: a29a114e591f9d3f /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism: - en: 4ffc57b0582af92c - zh: 8c6fe31d52065f2b + en: a37ea37f2d527693 + zh: 749b5a263d0e1534 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists: - en: 60094d4cff20b7cc - zh: f332d2d2ba2d8170 + en: 9c8d9af84f9abd99 + zh: 1c82b355aa5f210f /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner: - en: 7daba96f71c4b02b - zh: a92e6ae40dc118a7 + en: 0847de0e53e50061 + zh: 90fa3dcaf080c3be /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries: - en: 0f87fd641ace67fb - zh: c9fa22ca5225f264 + en: ad02570eae90037f + zh: fd78c2318de18dda /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/header-verification-and-source-map: en: c19df6b7a756b779 zh: b8267b26bd191d97 @@ -57,11 +57,11 @@ en: 817eb1b7de13872e zh: 06fe175e41e7459b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: - en: 4cc129728a9005b7 - zh: 89fac806fb43b41b + en: 046831a864786b2a + zh: 4a261fbab3396ea4 /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note/future-warnings-and-cleanup-surfaces: - en: ea48de0150a65991 - zh: 2b98931436064513 + en: 8872c7b4c8ce1cc7 + zh: c7fb4eefa013c7b0 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index c953838294..732ca20a35 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes and deletes to the workspace, explicit extra directories, and a private temporary directory: `workspace-write` grants these directories, `read-only` grants none. Mount `dsh-sandbox-local` for confined bash and PowerShell commands, or use `AclSandbox`; Win32 failures never cause unrestricted spawning. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. +On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -39,17 +39,18 @@ Choose it for Windows compositions that confine subprocess file effects under `r import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grants. workspace-write derives one SID per writable directory -// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes. +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, + writeSid: workspaceWriteSid(workspaceRoot), tempWriteSid: tempWriteSid(tempDir), mode: 'workspace-write', }) @@ -58,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -The writable-directory security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you -Under `workspace-write`, the child may write into the workspace, its explicit extra roots, and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. +Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. @@ -94,11 +95,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry a capability for each writable directory and a separate private-temp capability, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share authority for their permitted directories without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. 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; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: standing directory grants from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a 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. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -109,26 +110,26 @@ Authenticated Users is absent from both lists — the WMI namespace security che The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's runner-failure rules match that signature. ```sh -node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting a standing directory ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root. +The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant. When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation. ### Verified boundaries - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. -- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working where the directory DACL grants them DELETE directly. +- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. - **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite. - **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. -- **Hard links are file-object aliases, not path aliases** — an inheritable directory grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. +- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — directory ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. -- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — each granted directory's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside that directory, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. - **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. -- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing directory SIDs cannot write one another's temp directories. +- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. ### Header verification and source map @@ -178,12 +179,12 @@ One extra catalog entry on Windows: the skill's description enters context with These limits define when the backend is a poor fit or needs special operational care. They are current package constraints, not a general Windows comparison or a task backlog. -- **Directory grants remain cached for the provider lifetime** — the provider retains an `AclWriteGrant` and its native SID allocations for every canonical directory it has granted, until provider disposal. New executions select the permitted directory SIDs; changing that selection does not release cached grants or alter running tokens. +- **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. 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 can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual removal reclaims it. -- **Standing edits also affect extra directories** — the directory capability ACE, Low label, and Everyone `FILE_DELETE_CHILD` deny persist after plugin removal and provider disposal, including on user directories outside the workspace such as `~/.dsh/memory`. Renaming a directory derives a new SID but leaves the old ACE behind; the standing Low label and deny retain the effects described above. +- **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), and a future cleanup command may reap them. - **NULL-DACL directories are not identity-preserving under grant+revoke** — a directory with a NULL DACL 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. Real workspace and temp directories carry real DACLs, so this stays a documented edge. - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. -- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-directory identity pays it once per directory per machine, and the exact-ACE skip makes every later provision cheap. +- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **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. - **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access. - **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. @@ -200,6 +201,6 @@ This Dev Note is working context for maintainers: undecided directions and open #### Future: warnings and cleanup surfaces -The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing directory ACEs from renamed directories is undecided. Both are open directions, not shipped behavior. +The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing workspace ACEs from renamed workspaces is undecided. Both are open directions, not shipped behavior. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index f47ff9aef4..ad9de70659 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入与删除限制在工作区、显式额外目录和私有临时目录内:`workspace-write` 授予这些目录,`read-only` 均不授予。挂载 `dsh-sandbox-local` 可约束 bash 与 PowerShell 命令,也可使用 `AclSandbox`;Win32 失败绝不会导致不受限制的进程启动。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -39,17 +39,18 @@ kind: "package-library" import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grants. workspace-write derives one SID per writable directory -// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes. +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], tempDir, + writeSid: workspaceWriteSid(workspaceRoot), tempWriteSid: tempWriteSid(tempDir), mode: 'workspace-write', }) @@ -58,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -可写目录的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 -在 `workspace-write` 下,子进程可以写入工作区、显式额外根目录及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 +在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 @@ -96,11 +97,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 为每个可写目录携带一项能力,并另带独立的私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享其获准目录的权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 -`workspace-write`(登录 SID、Everyone、目录 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 +`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。 @@ -111,26 +112,26 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全 面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 runner 失败规则匹配该签名。 ```sh -node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻目录 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。 +seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。 启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。 ### 已验证边界 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 -- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,前提是该目录 DACL 直接向其授予 DELETE。 +- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 - **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。 - **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 -- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承目录授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 目录 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 -- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 每个授权目录的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除该目录内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 - **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 -- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享目录 SID 的两个令牌无法写入彼此的临时目录。 +- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 ### 头文件验证与源码索引 @@ -180,12 +181,12 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 这些限制说明后端何时不合适,或何时需要特别运维。它们是当前包约束,不是通用 Windows 对比或任务积压。 -- **目录授权缓存保留到提供方释放**——提供方为每个曾授权的规范目录保留一个 `AclWriteGrant` 及其原生 SID 分配,直到提供方释放。新执行选择获准目录的 SID;改变该选择不会释放缓存授权或修改运行中的令牌。 +- **每个工作区一个写入白名单**——写入 SID 是白名单的基本单位,且就是工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 - **清理按设计尽力而为**——`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。 -- **常驻改动同样影响额外目录**——目录能力 ACE、Low 标签与针对 Everyone 的 `FILE_DELETE_CHILD` 拒绝项会在插件移除和提供方释放后保留,包括工作区之外的用户目录,如 `~/.dsh/memory`。目录改名会派生新的 SID,但留下旧 ACE;常驻 Low 标签与拒绝项继续产生上文描述的效果。 +- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID),未来的清理命令可以回收它们。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形。 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 -- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按目录身份每台机器每个目录只付一次,精确 ACE 跳过让后续每次供给都很便宜。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 @@ -202,6 +203,6 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 #### 未来:警告与清理表面 -对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名目录常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。 +对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名工作区常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index ae4e486099..7f7bcc4c57 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,13 +1,13 @@ /** * Server-side write-grant materialization. The sandbox seam holds one - * standing grant per writable directory and one revocable temp grant per - * live session/workspace pair. Directory identities survive by deterministic + * standing workspace grant per workspace and one revocable temp grant per + * live session/workspace pair. Workspace identities survive by deterministic * derivation and their standing ACE; temp identities derive from random * private paths and are deliberately new after a restart. * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every - * revocable grant, leaves the standing directory edits in place, and reports + * revocable grant, leaves the standing workspace edits in place, and reports * every cleanup failure. * @module @deepseek-ai/dsh-sandbox-windows-acl/grant */ @@ -58,7 +58,7 @@ export class AclWriteGrant { * and the world SID their ambient-delete deny names, and open the binding * table (lazily, once per server). Fail-closed: any failure throws — nothing * is granted yet. - * @param writeSid - the directory (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. + * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). */ @@ -91,21 +91,19 @@ export class AclWriteGrant { * Grant the write ACE, the ambient-delete deny, and the Low mandatory label * on one directory (idempotent: an already-standing exact ACE, deny, and * label skip the eager full-tree re-propagation — see {@link grantWrite}) - * and record the path once for {@link dispose} unless it is standing. Repeated - * calls recheck the ACE so a recreated directory receives its grant. The path is + * 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 and label gain the grant. - * @param standing - the edits outlive this grant (the directory reuse + * @param standing - the edits outlive this grant (the workspace reuse * cache; dispose() skips revoking it). Default false (revoked on * dispose — the temp-directory lifecycle). */ add(path: string, standing = false): void { - const paths = standing ? this.standingPaths : this.revocablePaths - if (!paths.includes(path)) paths.push(path) + ;(standing ? this.standingPaths : this.revocablePaths).push(path) grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr) } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 41e5c8500e..5de5d47b72 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,9 +2,9 @@ * 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 distinct directory and temp write + * token whose restricting SIDs include distinct workspace and temp write * SIDs that this sandbox adds to their owning directories' DACLs — the - * intersection check then allows writes exactly where a permitted capability has + * intersection check then allows writes exactly where either capability has * a Write ACE, and nowhere else those SIDs are concerned (the check ALSO * inherits the ambient write ACEs of the other restricting SIDs — the * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, @@ -13,12 +13,13 @@ * for the complete boundary). The intersection covers only the object's own * access check, so the token is also lowered to Low integrity and every * granted directory carries a Low no-write-up label and the ambient-delete - * deny the `acl` module documents. Each canonical writable directory has a - * stable SID ({@link directoryWriteSid}) granted only to that directory. - * An execution carries the SIDs of its allowed directories; standing ACEs - * are reusable across sessions and permission combinations. Each private - * temp directory instead receives its own SID, so sibling sessions cannot - * use a shared directory capability to enter one another's temp + * deny the `acl` module documents. 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. Each private temp directory instead receives its own SID, + * so sibling sessions sharing a workspace cannot enter one another's temp * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * @@ -42,7 +43,7 @@ * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { existsSync, realpathSync, statSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' import { Win32Error } from '@deepseek-ai/dsh-win32-process' @@ -55,12 +56,10 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' -export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts' -import { directoryWriteSid } from './workspace-sid.ts' - -export { tempWriteSid, directoryWriteSid } from './workspace-sid.ts' +export { assertTempRootOutsideWorkspace } from './path-boundary.ts' +export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' -/** Construction options: the writable-directory and private-temp allowlists and their distinct SID identities. */ +/** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] @@ -70,11 +69,19 @@ export interface AclSandboxOptions { * root is never an implicit grant. Read-only accepts only null/undefined. */ tempDir?: string | null + /** + * 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 private temp directory's write SID. Required whenever * workspace-write grants a temp directory, absent otherwise. It must be - * distinct from every directory write SID, so sibling sessions sharing a workspace - * cannot use a standing directory capability in one another's temp tree. + * distinct from {@link writeSid}, so sibling sessions sharing a workspace + * cannot use the standing workspace capability in one another's temp tree. */ tempWriteSid?: string /** @@ -88,7 +95,7 @@ export interface AclSandboxOptions { /** * Whether this instance owns its DACL grants (default true). False means * the CALLER has already materialized the ACEs (the sandbox seam's - * directory/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — + * workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — * the caller holds the grants for its own lifetime and revokes them. */ manageDacls?: boolean @@ -148,16 +155,16 @@ function freeSidBestEffort( * 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 directory ACEs in place (the cross-instance reuse cache), frees + * 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 { - /** Canonical, deduplicated writable directories (constructor-validated). */ - readonly writableDirs: readonly string[] - /** The independently derived write SIDs, in the same order as writableDirs; empty under read-only. */ - readonly writeSids: readonly string[] + /** Absolute writable directories (constructor-validated). */ + readonly writableDirs: string[] + /** The workspace SID string whose ACEs form the workspace allowlist. */ + readonly writeSid: string | undefined /** The private temp directory's write SID (workspace-write with temp only). */ readonly tempWriteSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ @@ -167,7 +174,7 @@ export class AclSandbox { private tempDirResolved: string | null | undefined private api: Win32Bindings | undefined private token: NativePtr | undefined - private readonly directorySidPtrs = new Map() + private writeSidPtr: NativePtr | undefined private tempWriteSidPtr: NativePtr | undefined /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */ private sidAllocations: NativePtr[] = [] @@ -176,23 +183,26 @@ export class AclSandbox { constructor(options: AclSandboxOptions) { this.mode = options.mode this.manageDacls = options.manageDacls ?? true - this.writableDirs = [...new Set(options.writableDirs.map((directory) => { + 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 realpathSync.native(absolute) - }))] + return absolute + }) this.tempDirOption = options.tempDir - this.writeSids = this.mode === 'workspace-write' ? this.writableDirs.map(directoryWriteSid) : [] + this.writeSid = options.writeSid this.tempWriteSid = options.tempWriteSid + 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()') + } if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') } if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) { throw new Error('AclSandbox read-only does not accept a temp directory') } - if (this.mode === 'read-only' && this.tempWriteSid !== undefined) { + if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) { throw new Error('AclSandbox read-only does not accept write SIDs') } if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) { @@ -201,7 +211,7 @@ export class AclSandbox { if (this.tempDirOption === null && this.tempWriteSid !== undefined) { throw new Error('AclSandbox temp write SID requires a temp directory') } - if (this.tempWriteSid !== undefined && this.writeSids.includes(this.tempWriteSid)) { + if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) { throw new Error('AclSandbox workspace and temp write SIDs must be distinct') } } @@ -228,9 +238,7 @@ export class AclSandbox { if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) return parsedSid } - for (const [index, sid] of this.writeSids.entries()) { - this.directorySidPtrs.set(this.writableDirs[index] as string, parseSid(sid)) - } + this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid) this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption @@ -248,7 +256,7 @@ export class AclSandbox { // 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-directory reuse cache — dispose() never revokes them, or the next + // 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 before the private directory is // deleted; the ambient temp root is never granted). @@ -258,9 +266,9 @@ export class AclSandbox { this.sidAllocations.push(lowLabelSid, worldSid) if (this.manageDacls) { - if (this.mode === 'workspace-write') { - for (const [path, sidPtr] of this.directorySidPtrs) { - grantWrite(api, path, sidPtr, lowLabelSid, worldSid) + if (this.writeSidPtr !== undefined) { + for (const path of this.writableDirs) { + grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful @@ -273,8 +281,7 @@ export class AclSandbox { } const logonSid = findLogonSid(api, currentToken) this.sidAllocations.push(logonSid) - const writeSids = [...this.directorySidPtrs.values()] - if (this.tempWriteSidPtr !== undefined) writeSids.push(this.tempWriteSidPtr) + const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) restrictedToken = createRestrictedToken( api, currentToken, logonSid, writeSids, { world: worldSid }, @@ -289,18 +296,18 @@ export class AclSandbox { // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every // piped-stdio grandchild spawn. Merge a full-access ACE for a // restricting SID (the PRIVATE temp SID when present, otherwise the - // first directory SID, or Everyone when neither is present): new-object creation + // workspace SID, or 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. Choosing the temp SID prevents default-DACL // objects in one session's temp tree from acquiring the shared - // directory capability. - setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.directorySidPtrs.values().next().value ?? worldSid) + // workspace capability. + setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') currentTokenOpen = false this.api = api } catch (error) { // Fail-closed cleanup: never leave a revocable (temp) grant or SID - // allocation behind a failed init. Standing directory ACEs are NOT + // 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[] = [] @@ -317,13 +324,14 @@ export class AclSandbox { cleanupFailures.push(cleanupError) } } - for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', cleanupFailures) - freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', cleanupFailures) + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + freeSidBestEffort(api, sidPtr, label, cleanupFailures) + } for (const sidPtr of this.sidAllocations.splice(0)) { freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures) } this.token = undefined - this.directorySidPtrs.clear() + this.writeSidPtr = undefined this.tempWriteSidPtr = undefined this.tempDirResolved = undefined this.grantedPaths = [] @@ -395,7 +403,7 @@ export class AclSandbox { /** * Revoke the revocable (temp) grants, free the SID, close the token; the - * standing directory ACEs stay (the reuse cache). Reports every cleanup + * standing workspace ACEs stay (the reuse cache). Reports every cleanup * failure. */ dispose(): void { @@ -411,8 +419,9 @@ export class AclSandbox { } } } - for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', failures) - freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', failures) + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + freeSidBestEffort(api, sidPtr, label, failures) + } const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always has its token; the guard mirrors the write-SID guard. */ @@ -428,7 +437,7 @@ export class AclSandbox { } this.api = undefined this.token = undefined - this.directorySidPtrs.clear() + this.writeSidPtr = undefined this.tempWriteSidPtr = undefined this.grantedPaths = [] if (failures.length > 0) { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index a79aa33c32..8c673780a2 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,19 +1,20 @@ /** * 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 per-directory write-SID allowlist, spawns the + * 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 (directory ACEs stay standing as the reuse cache). + * 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): * [node, runner.js, '--workspace', , '--temp', , - * '--mode', , ['--writable-root', ]..., - * ['--write-sid', ]..., ['--temp-write-sid', ], '--', ] + * '--mode', , + * ['--write-sid', , + * '--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: each writable directory and private temp carry distinct + * - workspace-write: the workspace and temp directories carry distinct * capability-SID Write grants; other ACL-addressable writes are denied * except for the documented Everyone and hard-link boundaries. * - read-only: no capability-SID grants; the restricting list carries no @@ -23,11 +24,11 @@ * Public tree writes are denied); the two lists share the keep-alive group * (logon SID, EVERYONE) and differ only by the capabilities. * - * Repeated `--write-sid` plus `--temp-write-sid`: the seam's grant contract — the - * CALLER has already materialized per-directory and private-temp ACEs + * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the + * CALLER has already materialized distinct workspace and private-temp ACEs * and owns their revocation, so the runner neither grants nor revokes - * (`manageDacls: false`). Each SID is checked against its owning directory path. - * Without these SID flags (standalone/agentless use), workspace-write treats + * (`manageDacls: false`). Both values are checked against their owning paths. + * Without the pair (standalone/agentless use), workspace-write treats * `--temp` as a ROOT, creates a random private child directory, derives its * own temp SID, and removes that directory after the child exits. In both * flows the runner rewrites TMP/TEMP in its OWN environment to the private @@ -44,12 +45,12 @@ */ import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control' -import { closeSync, existsSync, mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs' +import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { win32 } from './ffi.ts' import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts' -import { tempWriteSid, directoryWriteSid } from './workspace-sid.ts' +import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -66,19 +67,17 @@ interface ParsedArgs { workspace: string temp: string mode: 'read-only' | 'workspace-write' - writableRoots: string[] - writeSids: string[] + writeSid: string | undefined tempWriteSid: string | undefined command: string args: string[] } function parseArgs(raw: string[]): ParsedArgs { - const writableRoots: string[] = [] let workspace: string | undefined let temp: string | undefined let mode: string | undefined - const writeSids: string[] = [] + let writeSid: string | undefined let parsedTempWriteSid: string | undefined let index = 0 for (; index < raw.length; index++) { @@ -92,10 +91,9 @@ function parseArgs(raw: string[]): ParsedArgs { if (value === undefined) fail(`missing value after ${token}`) switch (token) { case '--workspace': workspace = value; break - case '--writable-root': writableRoots.push(value); break case '--temp': temp = value; break case '--mode': mode = value; break - case '--write-sid': writeSids.push(value); break + case '--write-sid': writeSid = value; break case '--temp-write-sid': parsedTempWriteSid = value; break default: fail(`unknown argument: ${token}`) } @@ -106,7 +104,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, writableRoots, writeSids, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -121,21 +119,16 @@ async function main(): Promise { // a bogus root must fail loudly at the runner boundary, never mid-child. requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) - for (const root of parsed.writableRoots) requireDirectory('--writable-root', root) - const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots].map(directory => realpathSync.native(directory)))] - const seamManaged = parsed.writeSids.length > 0 || parsed.tempWriteSid !== undefined - if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) { - fail('read-only does not accept --writable-root') - } + const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined if (parsed.mode === 'read-only' && seamManaged) { fail('read-only does not accept --write-sid or --temp-write-sid') } - if (parsed.mode === 'workspace-write' && (parsed.writeSids.length === 0) !== (parsed.tempWriteSid === undefined)) { + if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { fail('workspace-write requires --write-sid and --temp-write-sid together') } if (parsed.mode === 'workspace-write') { - for (const root of writableDirs) assertTempRootOutsideWorkspace(root, parsed.temp) + assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp) } const api = await win32() @@ -151,13 +144,12 @@ async function main(): Promise { let initialized = false try { let privateTempDir: string | null = null + let writeSid: string | undefined let privateTempSid: string | undefined if (parsed.mode === 'workspace-write') { + writeSid = workspaceWriteSid(parsed.workspace) if (seamManaged) { - if (parsed.writeSids.length !== writableDirs.length - || writableDirs.some((directory, index) => parsed.writeSids[index] !== directoryWriteSid(directory))) { - fail('--write-sid must match each writable directory in order') - } + if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace') privateTempDir = parsed.temp privateTempSid = tempWriteSid(privateTempDir) if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') @@ -168,9 +160,10 @@ async function main(): Promise { } } sandbox = new AclSandbox({ - writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [], + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: privateTempDir, mode: parsed.mode, + ...writeSid === undefined ? {} : { writeSid }, ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid }, manageDacls: !seamManaged, }) diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 41e44dd322..25e06ae3f4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -101,8 +101,7 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { * (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 - * private-temp SID, otherwise the first directory SID, or Everyone when - * neither is present), so each new + * 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. @@ -183,7 +182,7 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (verified on Win11 26200, see the POC-worktree restrict-variant harness): * - read-only: [logon SID, EVERYONE] - * - workspace-write: [logon SID, EVERYONE, ...directory SIDs, optional temp SID] + * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID] * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — @@ -192,7 +191,7 @@ export interface RestrictingSidSet { * 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 that - * directory grant inert under read-only while the unrevoked ACE keeps the + * workspace grant inert under read-only while the unrevoked ACE keeps the * re-upgrade free (the grant's exact-ACE skip — no re-propagation). * Everyone's own ambient grants remain the documented partial boundary. * Authenticated Users is absent from BOTH lists: the WMI @@ -208,7 +207,7 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSids - the distinct write SIDs forming the directory and + * @param writeSids - the distinct write SIDs forming the workspace and * optional temp allowlists (workspace-write only; empty under read-only). * @param known - the well-known SIDs entering the restricting list. * @param mode - selects the restricting list (workspace-write adds the capability SIDs). diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index 1a446e5cbf..db313ce092 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -1,19 +1,39 @@ /** - * Stable write identities for individual canonical directories, with separate - * identities for private temp directories. + * 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. Temporary directories use a separate, + * per-directory identity from {@link tempWriteSid}; sharing the workspace + * identity with temp would let sibling sessions write one another's temp + * trees. + * + * 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 one directory's write SID. Grant this SID only on that directory; - * executions carry the SIDs of all directories they are allowed to write. - * @param directory - the canonical directory path. - * @returns the deterministic SDDL string, preserving existing workspace identities. + * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, + * matching the workspace-capability shape the token and ACE layers carry). + * @param workspaceRoot - the canonical workspace path. + * @returns the SDDL string form. */ -export function directoryWriteSid(directory: string): string { - const digest = createHash('sha256').update(directory, 'utf8').digest() +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}` @@ -22,7 +42,7 @@ export function directoryWriteSid(directory: string): string { /** * Derive one private temp directory's write SID. The random directory path * is the capability identity; a fixed third subauthority domain-separates - * the result from every two-subauthority directory SID. + * the result from every two-subauthority workspace SID. * @param tempDir - the private temp directory's absolute path. * @returns the SDDL string form. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index fd97c0600c..3bf86f4d43 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,14 +9,14 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' -import { AclSandbox, directoryWriteSid } from '../src/index.ts' +import { AclSandbox } from '../src/index.ts' import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' @@ -209,7 +209,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { }) function scratch(): string { - const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-edit-'))) + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')) scratchDirs.push(dir) return dir } @@ -364,11 +364,11 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) - it('interleaved sandbox instances reuse one standing directory 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, mode: 'workspace-write' }) - const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }) + 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 @@ -377,29 +377,8 @@ describe.skipIf(!isWin32)('ACL editing', () => { sandboxA.dispose() sandboxB.dispose() const aces = readDirectAces(api, dir) - expect(aces.filter(ace => ace.sid === directoryWriteSid(dir))).toHaveLength(1) - }) - - it('shares a directory capability across sandbox sets without granting it on their other directories', async () => { - const api = await win32() - const workspaceA = scratch() - const workspaceB = scratch() - const memory = scratch() - const sandboxA = new AclSandbox({ writableDirs: [workspaceA, memory], tempDir: null, mode: 'workspace-write' }) - const sandboxB = new AclSandbox({ writableDirs: [workspaceB, memory], tempDir: null, mode: 'workspace-write' }) - try { - await sandboxA.init() - await sandboxB.init() - const dirs = [workspaceA, workspaceB, memory] - const directorySids = dirs.map(directoryWriteSid) - for (const dir of dirs) { - const granted = readDirectAces(api, dir).filter(ace => directorySids.includes(ace.sid)) - expect(granted.map(ace => ace.sid)).toEqual([directoryWriteSid(dir)]) - } - } finally { - sandboxA.dispose() - sandboxB.dispose() - } + 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 label and keeps the standing workspace pair (self-managed flow)', async () => { @@ -409,13 +388,14 @@ describe.skipIf(!isWin32)('ACL editing', () => { const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, + writeSid: 'S-1-4-9000-3', tempWriteSid: 'S-1-4-9000-3-1', mode: 'workspace-write', }) await sandbox.init() sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) - expect(workspaceAces.some(ace => ace.sid === directoryWriteSid(workspaceDir))).toBe(true) + expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) expect(readLabelAces(api, workspaceDir)).toEqual([ { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, ]) @@ -427,12 +407,13 @@ describe.skipIf(!isWin32)('ACL editing', () => { it('rejects an overlapping private temp directory before applying either capability', async () => { const workspaceDir = scratch() const nestedTemp = join(workspaceDir, 'temp') - const writeSid = directoryWriteSid(workspaceDir) + const writeSid = 'S-1-4-9000-30' const privateTempSid = 'S-1-4-9000-30-1' mkdirSync(nestedTemp) const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir: nestedTemp, + writeSid, tempWriteSid: privateTempSid, mode: 'workspace-write', }) @@ -443,7 +424,21 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false) }) - it('workspace-write requires a nonempty token capability list', () => { + 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(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires an explicit private temp directory or null/) + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires a temp write SID/) + expect(() => new AclSandbox({ + writableDirs: [dir], + tempDir: dir, + writeSid: 'S-1-4-1-1', + tempWriteSid: 'S-1-4-1-1', + mode: 'workspace-write', + })).toThrow(/must be distinct/) expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) .toThrow(/requires at least one write SID/) }) @@ -502,10 +497,10 @@ 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, mode: 'workspace-write' }) + 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 === sandbox.writeSids[0]) + 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) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts index acaa4a010d..43810249ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -8,7 +8,7 @@ */ import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -53,7 +53,6 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { // grant stays exactly one ACE (the reuse across sessions/restarts). grant.add(dir) grant.add(standingDir, true) - expect(grant.paths).toEqual([standingDir, dir]) expect(icaclsText(dir)).toContain('S-1-4-9000-77') expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') grant.dispose() @@ -61,20 +60,6 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') }) - it('restores a standing ACE on a recreated directory without duplicating its tracked path', () => { - const dir = scratch() - const grant = AclWriteGrant.create('S-1-4-9000-80') - try { - grant.add(dir, true) - rmSync(dir, { recursive: true }) - mkdirSync(dir) - expect(icaclsText(dir)).not.toContain(grant.writeSid) - grant.add(dir, true) - expect(icaclsText(dir)).toContain(grant.writeSid) - expect(grant.paths).toEqual([dir]) - } finally { grant.dispose() } - }) - it('two grants with different SIDs coexist and revoke independently', () => { const dir = scratch() const grantA = AclWriteGrant.create('S-1-4-9000-78') diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 43e9a1f3eb..64aac56080 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -8,7 +8,7 @@ * acl.spec.ts and runner.spec.ts (win32 only). */ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Win32Error } from '@deepseek-ai/dsh-win32-process' @@ -18,7 +18,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { AclSandbox, directoryWriteSid } from '../src/index.ts' +import { AclSandbox } from '../src/index.ts' import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') @@ -29,7 +29,6 @@ type MockFn = ReturnType interface HappyStubs { api: Win32Bindings setNamedSecurityInfoW: MockFn - setEntriesInAclW: MockFn convertStringSidToSidW: MockFn closeHandle: MockFn localFree: MockFn @@ -179,7 +178,7 @@ function happyStubs(): HappyStubs { localFree, closeHandle, getLastError, formatMessageW, }) return { - api, setNamedSecurityInfoW, setEntriesInAclW, convertStringSidToSidW, closeHandle, localFree, + api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, } } @@ -198,46 +197,28 @@ describe('AclSandbox constructor validation', () => { it('resolves relative writable directories to absolute paths', () => { const dir = scratch() const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) - expect(sandbox.writableDirs).toEqual([realpathSync.native(dir)]) + expect(sandbox.writableDirs).toEqual([resolve(dir)]) expect(sandbox.mode).toBe('read-only') expect(sandbox.tempDir).toBeUndefined() }) - it('derives one SID per canonical directory and keeps read-only free of directory capabilities', () => { - const workspace = scratch() - const memory = scratch() - const dirs = [workspace, memory, workspace] - const sandbox = new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'workspace-write' }) - const canonical = [workspace, memory].map(path => realpathSync.native(path)) - expect(sandbox.writableDirs).toEqual(canonical) - expect(sandbox.writeSids).toEqual(canonical.map(directoryWriteSid)) - expect(new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'read-only' }).writeSids).toEqual([]) - }) - it('rejects temp authority under read-only', () => { const workspace = scratch() const temp = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' })) .toThrow(/read-only does not accept a temp directory/u) + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' })) + .toThrow(/read-only does not accept write SIDs/u) expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' })) .toThrow(/read-only does not accept write SIDs/u) }) - it('requires a distinct SID for private temp writes', () => { - const workspace = realpathSync.native(scratch()) - const temp = scratch() - expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'workspace-write' })) - .toThrow(/requires a temp write SID/u) - expect(() => new AclSandbox({ - writableDirs: [workspace], tempDir: temp, tempWriteSid: directoryWriteSid(workspace), mode: 'workspace-write', - })).toThrow(/must be distinct/u) - }) - it('rejects a temp SID when temp writes are disabled', () => { const workspace = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, + writeSid: 'S-1-4-9000-2', tempWriteSid: 'S-1-4-9000-2-1', mode: 'workspace-write', })).toThrow(/temp write SID requires a temp directory/u) @@ -252,6 +233,7 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, + writeSid: 'S-1-4-9000-1', tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write', }) @@ -260,50 +242,16 @@ describe('AclSandbox init', () => { expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) }) - it.each([true, false])('places directory SIDs in the token and selects the default DACL (private temp: %s)', async (privateTemp) => { - const { convertStringSidToSidW, createRestrictedToken, setEntriesInAclW } = state.stubs as HappyStubs - const workspace = scratch() - const memory = scratch() - const temp = privateTemp ? scratch() : null - const parsed: bigint[] = [] - convertStringSidToSidW.mockImplementation((_sid: string, slot: NativePtr) => { - const pointer = BigInt(101 + parsed.length) - parsed.push(pointer) - koffi.encode(slot, PVOID, pointer) - return 1 - }) - const sandbox = new AclSandbox({ - writableDirs: [workspace, memory], tempDir: temp, mode: 'workspace-write', - ...(privateTemp ? { tempWriteSid: 'S-1-4-9000-1-1' } : {}), - }) - try { - await sandbox.init() - const expectedSids = [...sandbox.writeSids] - if (sandbox.tempWriteSid !== undefined) expectedSids.push(sandbox.tempWriteSid) - expect(convertStringSidToSidW).toHaveBeenCalledTimes(expectedSids.length) - expectedSids.forEach((sid, index) => { - expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything()) - }) - const call = createRestrictedToken.mock.calls[0]! - expect(call[6]).toBe(expectedSids.length + 2) - const entries = call[7] as Buffer - expect(parsed.map((_pointer, index) => entries.readBigUInt64LE((index + 2) * abi.SID_AND_ATTRIBUTES_SIZE))).toEqual(parsed) - const defaultDaclMerge = setEntriesInAclW.mock.calls.find(call => call[2] === 88n)! - const defaultEntry = defaultDaclMerge[1] as Buffer - expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[privateTemp ? 2 : 0]) - } finally { sandbox.dispose() } - }) - it('requires an explicit private temp directory or null under workspace-write', () => { const workspace = scratch() - expect(() => new AclSandbox({ writableDirs: [workspace], mode: 'workspace-write' })) + expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })) .toThrow(/requires an explicit private temp directory or null/u) }) it('applies no grants when the temp dir option is null', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) await sandbox.init() expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only }) @@ -313,6 +261,7 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), + writeSid: 'S-1-4-9000-4', tempWriteSid: 'S-1-4-9000-4-1', mode: 'workspace-write', }) @@ -320,14 +269,11 @@ describe('AclSandbox init', () => { }) it('builds a read-only token without parsing a write SID or applying grants', async () => { - const { convertStringSidToSidW, setNamedSecurityInfoW, createRestrictedToken } = state.stubs as HappyStubs + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() - const memory = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'read-only' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) await sandbox.init() expect(convertStringSidToSidW).not.toHaveBeenCalled() - expect(createRestrictedToken.mock.calls[0]![6]).toBe(2) - expect(sandbox.writeSids).toEqual([]) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free }) @@ -335,7 +281,7 @@ describe('AclSandbox init', () => { it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write', manageDacls: false }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) await sandbox.init() expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke @@ -343,7 +289,7 @@ describe('AclSandbox init', () => { it('refuses a second init on the same instance', async () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) await sandbox.init() await expect(sandbox.init()).rejects.toThrow(/already initialized/u) }) @@ -352,44 +298,23 @@ describe('AclSandbox init', () => { const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs convertStringSidToSidW.mockReturnValue(0) const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() }) - it('frees earlier directory SIDs when parsing a later directory SID fails', async () => { - const { convertStringSidToSidW, localFree, setNamedSecurityInfoW, closeHandle } = state.stubs as HappyStubs - const workspace = scratch() - const memory = scratch() - const firstSid = 101n as NativePtr - convertStringSidToSidW.mockImplementationOnce((_sid: string, slot: NativePtr) => { - koffi.encode(slot, PVOID, firstSid) - return 1 - }).mockReturnValueOnce(0) - const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'workspace-write' }) - await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) - expect(convertStringSidToSidW).toHaveBeenCalledTimes(sandbox.writeSids.length) - sandbox.writeSids.forEach((sid, index) => { - expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything()) - }) - expect(localFree).toHaveBeenCalledWith(firstSid) - expect(setNamedSecurityInfoW).not.toHaveBeenCalled() - expect(closeHandle).toHaveBeenCalledWith(2n) - expect(sandbox.tempDir).toBeUndefined() - }) - it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { const { convertStringSidToSidW } = state.stubs as HappyStubs convertStringSidToSidW.mockImplementation(() => 1) // no out slot write const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) }) it('aggregates failed current and restricted token closes after init', async () => { const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) const restrictedToken = 99n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, @@ -436,6 +361,7 @@ describe('AclSandbox init', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, + writeSid: 'S-1-4-9000-10', tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) @@ -465,13 +391,13 @@ describe('AclSandbox spawn', () => { it('refuses to spawn before init', () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) }) it('pipe spawn drains empty pipes and settles with the child exit code', async () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) expect(child.pid).toBe(1234) @@ -484,7 +410,7 @@ describe('AclSandbox spawn', () => { it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { const { closeHandle } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) @@ -494,7 +420,7 @@ describe('AclSandbox spawn', () => { it('inherit spawn reports a failed close of the kill-on-close job', async () => { const { closeHandle, createJobObjectW } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) await sandbox.init() let jobHandle = 0n closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) @@ -507,7 +433,7 @@ describe('AclSandbox spawn', () => { describe('AclSandbox dispose', () => { it('is a no-op before init', () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) expect(() => { sandbox.dispose() }).not.toThrow() }) @@ -518,6 +444,7 @@ describe('AclSandbox dispose', () => { const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, + writeSid: 'S-1-4-9000-16', tempWriteSid: 'S-1-4-9000-16-1', mode: 'workspace-write', }) @@ -529,7 +456,7 @@ describe('AclSandbox dispose', () => { it('aggregates SID and token cleanup failures into an AggregateError', async () => { const { localFree } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) await sandbox.init() localFree.mockReturnValue(1n) expect(() => { sandbox.dispose() }).toThrow(AggregateError) @@ -538,7 +465,7 @@ describe('AclSandbox dispose', () => { it('reports a failed close of the restricted token', async () => { const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) let restrictedToken = 0n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index dfa56d1520..117052da1c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -52,6 +52,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, + writeSid: 'S-1-4-9000-4', tempWriteSid: 'S-1-4-9000-4-1', mode: 'workspace-write', }) @@ -91,15 +92,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('fails closed when the temp SID cannot be parsed (no unrestricted fallback)', async () => { + 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], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) it('failed init clears provisional temp state before a retry', async () => { - const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) const provisionalState = broken as unknown as { tempDirResolved: string | undefined } provisionalState.tempDirResolved = isolatedTemp diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index b639cac114..ebac7ffa0e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -6,14 +6,14 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, 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' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' -import { AclWriteGrant, tempWriteSid, directoryWriteSid } from '../src/index.ts' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -48,10 +48,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let publicProbeDir: string | undefined beforeAll(() => { - scratchRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))) + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) writableDir = join(scratchRoot, 'writable') mkdirSync(writableDir) - isolatedTemp = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-'))) + 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') @@ -107,65 +107,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('new processes use the exact writable set despite standing grants from a broader set', () => { - const extra = mkdtempSync(join(scratchRoot, 'extra-set-')) - const workspaceFile = join(writableDir, 'set-workspace.txt') - const extraFile = join(extra, 'set-extra.txt') - const probe = "const fs = require('node:fs'); for (const path of process.argv.slice(1)) { try { fs.writeFileSync(path, 'written'); console.log('ALLOW ' + path) } catch { console.log('DENY ' + path) } }" - const expanded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', - '--writable-root', extra, - '--', process.execPath, '-e', probe, workspaceFile, extraFile, - ]) - expect(expanded.error).toBeUndefined() - expect(expanded.signal).toBeNull() - expect(expanded.status, expanded.stderr).toBe(0) - expect(readFileSync(extraFile, 'utf8')).toBe('written') - writeFileSync(extraFile, 'retained') - - const narrowed = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', - '--', process.execPath, '-e', probe, workspaceFile, extraFile, - ]) - expect(narrowed.error).toBeUndefined() - expect(narrowed.signal).toBeNull() - expect(narrowed.status, narrowed.stderr).toBe(0) - expect(narrowed.stdout).toContain(`ALLOW ${workspaceFile}`) - expect(narrowed.stdout).toContain(`DENY ${extraFile}`) - expect(readFileSync(extraFile, 'utf8')).toBe('retained') - }, 90_000) - - it('accepts one provider-materialized SID per writable directory', () => { - const extra = mkdtempSync(join(scratchRoot, 'managed-extra-')) - const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-')) - const writeSid = directoryWriteSid(writableDir) - const extraSid = directoryWriteSid(extra) - const privateTempSid = tempWriteSid(privateTemp) - const directoryGrant = AclWriteGrant.create(writeSid) - const extraGrant = AclWriteGrant.create(extraSid) - const tempGrant = AclWriteGrant.create(privateTempSid) - try { - directoryGrant.add(writableDir, true) - extraGrant.add(extra, true) - tempGrant.add(privateTemp) - const target = join(extra, 'managed-write.txt') - const result = runRunner([ - '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', - '--writable-root', extra, '--write-sid', writeSid, '--write-sid', extraSid, '--temp-write-sid', privateTempSid, - '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target, - ]) - expect(result.error).toBeUndefined() - expect(result.signal).toBeNull() - expect(result.status, result.stderr).toBe(0) - expect(readFileSync(target, 'utf8')).toBe('managed') - } finally { - directoryGrant.dispose() - extraGrant.dispose() - tempGrant.dispose() - rmSync(privateTemp, { recursive: true, force: true }) - } - }, 90_000) - it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM availability follows the caller token', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", @@ -225,7 +166,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => { const seamWorkspace = join(scratchRoot, 'seam-workspace') mkdirSync(seamWorkspace) - const writeSid = directoryWriteSid(seamWorkspace) + const writeSid = workspaceWriteSid(seamWorkspace) const privateTemp = join(isolatedTemp, 'private-subdir') mkdirSync(privateTemp) const privateTempSid = tempWriteSid(privateTemp) @@ -261,7 +202,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 30_000) it('temp capabilities isolate sibling sessions that share one workspace SID', () => { - const writeSid = directoryWriteSid(writableDir) + const writeSid = workspaceWriteSid(writableDir) const tempA = join(isolatedTemp, 'session-a') const tempB = join(isolatedTemp, 'session-b') mkdirSync(tempA) @@ -392,7 +333,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // it, so the workspace write is denied instead of leaking through the // standing ACE. The switch back reuses the SAME standing ACE: the // re-upgrade write lands without any re-grant. - const writeSid = directoryWriteSid(writableDir) + const writeSid = workspaceWriteSid(writableDir) const privateTemp = join(isolatedTemp, 'mode-switch-temp') mkdirSync(privateTemp) const privateTempSid = tempWriteSid(privateTemp) @@ -509,7 +450,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const otherTemp = join(scratchRoot, 'other-temp') mkdirSync(otherWorkspace) mkdirSync(otherTemp) - const otherWorkspaceSid = directoryWriteSid(otherWorkspace) + const otherWorkspaceSid = workspaceWriteSid(otherWorkspace) const otherTempSid = tempWriteSid(otherTemp) const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid) const otherTempGrant = AclWriteGrant.create(otherTempSid) @@ -520,7 +461,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const ownTemp = join(scratchRoot, 'own-temp') mkdirSync(ownWorkspace) mkdirSync(ownTemp) - const ownWorkspaceSid = directoryWriteSid(ownWorkspace) + const ownWorkspaceSid = workspaceWriteSid(ownWorkspace) const ownTempSid = tempWriteSid(ownTemp) const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid) const ownTempGrant = AclWriteGrant.create(ownTempSid) @@ -597,7 +538,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { mkdirSync(child) writeFileSync(join(granted, 'file.txt'), 'x') writeFileSync(join(child, 'deep.txt'), 'x') - const grant = AclWriteGrant.create(directoryWriteSid(granted)) + const grant = AclWriteGrant.create(workspaceWriteSid(granted)) grant.add(granted, true) try { const probe = ` @@ -632,7 +573,7 @@ TryOpen 'DIRECTORY' '${child}' // not strip the label the second one's child still writes through. const shared = join(scratchRoot, 'shared-root') mkdirSync(shared) - const sidA = directoryWriteSid(shared) + const sidA = workspaceWriteSid(shared) const sidB = `${sidA}-1` const grantA = AclWriteGrant.create(sidA) const grantB = AclWriteGrant.create(sidB) @@ -701,26 +642,11 @@ TryOpen 'DIRECTORY' '${child}' expect(result.stderr).toContain('windows-acl-run: ') }, 15_000) - it('read-only rejects writable-directory flags before starting the command', () => { - const result = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', - '--writable-root', worldWritableDir, - '--', process.execPath, '-e', 'process.exit(99)', - ]) - expect(result.error).toBeUndefined() - expect(result.signal).toBeNull() - expect(result.status, result.stderr).toBe(127) - expect(result.stderr).toContain('read-only does not accept --writable-root') - }, 90_000) - it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { - const writeSid = directoryWriteSid(writableDir) + const writeSid = workspaceWriteSid(writableDir) const tempSid = tempWriteSid(isolatedTemp) const cases = [ ['--write-sid', writeSid], - ['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid], - ['--writable-root', worldWritableDir, '--write-sid', directoryWriteSid(worldWritableDir), '--write-sid', writeSid, '--temp-write-sid', tempSid], - ['--write-sid', writeSid, '--write-sid', directoryWriteSid(worldWritableDir), '--temp-write-sid', tempSid], ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], ] diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index bbb88ca38c..7fdef07d53 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -1,24 +1,31 @@ -/** Stable, distinct write identities for canonical directories and private temp paths. */ +/** + * 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), capability-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 { directoryWriteSid, tempWriteSid } from '../src/index.ts' +import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' -describe('directoryWriteSid', () => { - it('derives a stable capability-shaped SID per directory path', () => { - const first = directoryWriteSid('C:\\Users\\agent\\repo') - const second = directoryWriteSid('C:\\Users\\agent\\repo') +describe('workspaceWriteSid', () => { + it('derives a stable capability-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 directories', () => { - expect(directoryWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(directoryWriteSid('C:\\Users\\agent\\repo-b')) + 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(directoryWriteSid('C:\\Repo')).not.toBe(directoryWriteSid('c:\\repo')) - expect(directoryWriteSid('C:\\Repo\\')).not.toBe(directoryWriteSid('C:\\Repo')) + expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo')) + expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) }) }) @@ -27,7 +34,7 @@ describe('tempWriteSid', () => { const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123') expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u) - expect(temp).not.toBe(directoryWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) }) it('derives distinct capabilities for distinct private temp paths', () => { diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 095aa685e5..9bdd154f3a 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -24,8 +24,8 @@ en: c5540889017b6a7f zh: 679bfbd5254bf92d /deepseek-ai-dsh-sandbox/use-this-package/modes-and-enforcement: - en: aabe9791ece99ec1 - zh: 1dbc86eabb3a5548 + en: d3c906ddb4c47cbc + zh: d264b9103e3566d6 /deepseek-ai-dsh-sandbox/use-this-package/denied-calls-and-escalation: en: 3960c33a1e322cf9 zh: 4d85c962067fe7a1 @@ -45,8 +45,8 @@ en: 1b418d41f571ab3d zh: 62b289cdd6f4d272 /deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots: - en: b5001b6dc9a0d258 - zh: 08020a052df6856c + en: 6be9f7d4c65761d6 + zh: f00d9970ed58e3e0 /deepseek-ai-dsh-sandbox/further-exploration: en: 33d09277fb15d59f zh: 920ffe66671b56f3 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index d5de61a78c..3bd771b271 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -56,7 +56,7 @@ The mode names the file effects a command may perform; enforcement completeness | Mode | Effect | |---|---| | `read-only` | Denies writes except required sinks such as `/dev/null` | -| `workspace-write` | Allows writes under the workspace root, explicitly configured additional roots, and a backend-defined temp area | +| `workspace-write` | Allows writes under the workspace root, additional roots where supported, and a backend-defined temp area | | `danger-full-access` | Bypasses confinement; the consumer spawns its original argv | Enforcement is reported per call: `full` means the backend governs every promised file effect, while `partial` means an active backend or older kernel ABI governs only a subset — the Windows ACL rung and older Landlock ABIs are the current partial cases, so a consumer that requires the absolute boundary can reject or surface them. @@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o ### Writable roots -`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md). +`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. Windows subprocesses ignore additional roots; file tools still enforce them. See the [mode definitions](../../../docs/subsystems/sandbox.md). diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index f4b208450e..f2757c7300 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" | 模式 | 效果 | |---|---| | `read-only` | 拒绝写入,必需 sink(如 `/dev/null`)除外 | -| `workspace-write` | 允许写入工作区根目录、显式配置的额外根目录及后端定义的临时区域 | +| `workspace-write` | 允许写入工作区根目录、受支持的额外根目录及后端定义的临时区域 | | `danger-full-access` | 绕过隔离;消费方直接 spawn 原始 argv | 强制执行逐调用报告:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示活动后端或较旧的内核 ABI 只管辖子集——Windows ACL 档与较旧的 Landlock ABI 是当前的部分强制执行情形,需要绝对边界的消费方可以拒绝或向上暴露它们。 @@ -100,7 +100,7 @@ kind: "package-reference" ### 可写根目录 -`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 +`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。Windows 子进程忽略额外根目录;文件工具仍然执行这些授权。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 2d3eb81a5d..bd22dcb5d6 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -24,7 +24,7 @@ export { canonicalPath, workspaceWritableRoots, writableRoots, SandboxInvalidRoo /** * File-effect policy for confined processes. `read-only` permits only required * sinks such as `/dev/null`; `workspace-write` also permits the workspace, - * explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network + * explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network * and process visibility are outside this vocabulary. */ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -46,6 +46,7 @@ export interface SandboxExecutionPolicy { * Extra physical absolute directories `workspace-write` may also write under. * Absence means none. The enforcing host rejects missing, non-directory, and * symlinked entries on each call. Remote paths are checked on the remote host. + * Windows process confinement ignores these; harness filesystem operations honor them. * These grant nothing under `read-only` or `danger-full-access`. */ additionalWritableRoots?: readonly string[] @@ -53,7 +54,7 @@ export interface SandboxExecutionPolicy { * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. windows-acl gives * each live session/workspace pair a random private temp directory and SID, - * while standing grants are keyed by individual writable directories); absent + * while the workspace SID and standing grant remain per-workspace); absent * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index c0d908de1a..726f3bbfdc 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -70,9 +70,9 @@ export function canonicalPath(path: string): string { /** * The workspace-owned writable roots — the policy's workspace root plus its * explicit extra directories, as a canonical, deduplicated allow-list. Both - * the workspace and every extra root must be granted together by every - * backend; this is the one derivation of that set, kept separate from the - * platform temp policy each backend documents for itself (Seatbelt and the fs + * the workspace and every extra root are included by the filesystem fence and + * POSIX process backends; Windows process confinement ignores extra roots. + * This derivation is separate from each backend's platform temp policy (Seatbelt and the fs * fence use {@link writableRoots}; bwrap and Landlock add their own temp * grants). * @param policy - the file-effect policy to derive the allow-list from. diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl index 26571dd426..12a01c4fec 100644 --- a/snapshots/session/session-sandbox-root/session.v4.jsonl +++ b/snapshots/session/session-sandbox-root/session.v4.jsonl @@ -11,7 +11,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[11],"source":{"kind":"fallback"}}}