diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 9bb02e92b6..7a11b5e95f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: db793ab31a853a6627b2e30f4dd3f71542e3836d -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a3d69a2dd94de2fa4ef83aa16ee297c1939b767d +2026-08-08-windows-acl-restricted-token-sandbox.md: e93a066aca1a39fb177a7ae3c2f71f44724befd7 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index db793ab31a..e93a066aca 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID, Everyone, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented). ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation — Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index a3d69a2dd9..e4d5254504 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 登录 SID、Everyone、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录)。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4697e1267..f29675a109 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1181,7 +1181,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2019,7 +2019,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:47`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..cc645fa531 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:155`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index be2c311fd1..dd31511e20 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: af2b9043c52f35b7f38b368de1420d0e2856a963 -sandbox.zh.md: 5638e76769639525b1884756805d0e0cef1e870e +sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da +sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index af2b9043c5..a9a1fec080 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 5638e76769..99505265a9 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 48732dbbb0..c0e25df4cb 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -477,6 +477,29 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ### `sandbox/*` +#### `sandbox/acl-session` — log-only + +```ts persistence-catalog +/** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ +'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string +} +``` + +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) + #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/knip.json b/knip.json index f90e6c8f8d..e938425ffd 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "icacls", "python3", "sandbox-exec", "taskkill", diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 19f2c66290..cd371b4878 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: de89e8c5d1fcf78fcd47ebdb98c1d1fc3bb0bd5f -README.zh.md: 45ab3d9fa3c750984d903475b3c9e027e9e95d1c +README.md: 2fb5c7cdec44d023abd31b0913acdeead9e54eaf +README.zh.md: d8368dbd371be0f4b7530ba956b7cd7507486115 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index de89e8c5d1..2fb5c7cdec 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **The Windows temp grant is the real temp directory** — `workspace-write` confines writes to the workspace plus the host temp area (the same backend-defined choice the Landlock rung makes); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 45ab3d9fa3..d8368dbd37 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 的临时目录授权是真实 temp 目录**——`workspace-write` 把写入限制在工作区与宿主 temp 区域(与 Landlock 档位相同的后端定义选择);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..7e2e8e5c39 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', }, { name: 'SandboxMode', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1ad29d7d01..6db4d0b297 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -176,7 +176,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace', sessionId: 'agent' }, }]) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57b0ebb8e..c11a40ebc6 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -124,7 +124,7 @@ describe('pty-local real shell', () => { const created = await ctx.pty.spawn(agent, { type: 'shell' }) expect(sandbox.calls).toEqual([{ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) }, + policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' }, }]) await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 060026e284..c52b9c66fa 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts new file mode 100644 index 0000000000..c820694b11 --- /dev/null +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -0,0 +1,103 @@ +/** + * The windows-acl per-session write identity — the DURABLE half of the seam's + * per-session grant reuse. Each session owns exactly one record (one orphan + * write SID, one private temp subdirectory), stored as a log-only + * `sandbox/acl-session` event on the session log (the `sandbox/mode` + * precedent): replayable, never in the model transcript, and no external + * config store. The ACE half is server-lifetime state owned by the provider + * ({@link AclWriteGrant} materialization, revoked on dispose); the record + * survives restarts so a resumed session reuses the SAME SID — re-granting + * idempotently merges into (or skips) the standing ACEs instead of leaking a + * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a + * fresh record; the record's workspace must match the session's immutable + * cwd (asserted by the provider). + * + * @module dsh-sandbox-local/acl-session + */ + +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ + 'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string + } + } +} + +/** The durable per-session record carried by one `sandbox/acl-session` event. */ +export interface AclSessionRecord { + /** The orphan write SID whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the record was provisioned for. */ + workspace: string + /** The session's private temp subdirectory. */ + tempDir: string +} + +/** + * The session's windows-acl record: the last `sandbox/acl-session` event in + * the log, or undefined when the session has none (never confined on + * Windows). The pure fold — resume needs no catch-up machinery because + * replaying the log IS the state. + * @param events - session events in log order (other event types are skipped). + * @returns the last provisioned record, or undefined without one. + */ +export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'sandbox/acl-session') return event.data + } + return undefined +} + +/** + * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges + * across server restarts (the same SID re-grants the same directory) and OS + * temp hygiene may reclaim it — deliberately no GC here. + * @param sessionId - the session identity. + * @returns the private temp subdirectory path. + */ +export function sessionTempDir(sessionId: string): string { + const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) + return join(tmpdir(), `dsh-${digest}`) +} + +/** + * Provision the record for a session that has none (its first Windows + * confined execution): a fresh write SID plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (new session id) provisions a fresh record; resume replays the + * stored one. + * @param session - the session the record belongs to. + * @param workspaceRoot - the resolved policy root (the session's immutable cwd). + * @returns the provisioned record. + */ +export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { + const record: AclSessionRecord = { + writeSid: randomWriteSid(), + workspace: workspaceRoot, + tempDir: sessionTempDir(session.id), + } + session.append('sandbox/acl-session', record) + return record +} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 0b562616ea..183c6200ed 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -4,11 +4,18 @@ * competing candidates once, and reports each wrap's enforcement and stderr * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. + * + * The windows-acl rung additionally owns the per-session write grant: one + * orphan write SID and one private temp subdirectory per session (durable + * record in the session log — see `./acl-session.ts`), ACEs materialized + * lazily at the session's first confined execution and held for the SERVER + * process's lifetime (revoked on dispose). The runner receives `--write-sid` + * and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { @@ -22,6 +29,10 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId } from '@deepseek-ai/dsh-session' +import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { provisionAclSession, sessionAclRecord } from './acl-session.ts' +import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -205,9 +216,10 @@ const RUNNER_FAILURE_RULES = { } as const satisfies Record /** - * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless - * apart from the cached chain verdict — it spawns nothing but the one-time - * probes, so there is no disposal work beyond cordis' own. + * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the + * chain verdict and, on the windows-acl rung, the per-session write grants + * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the + * one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -225,6 +237,12 @@ export class LocalSandboxProvider extends SandboxProvider { private readonly probeTimeoutMs: number /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined + /** + * Server-lifetime per-session write grants (windows-acl rung), keyed by the + * session's orphan write SID — the native half of the per-session reuse; + * the durable half lives in the session log (`./acl-session.ts`). + */ + private readonly aclGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -246,6 +264,12 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) + // Standing ACL grants are revoked with the provider: a clean server + // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs + // the session's durable record re-grants idempotently on resume). + ctx.effect(() => () => { + this.revokeAclGrants() + }) } /** @@ -284,18 +308,124 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] - case 'windows-acl': return [ - ...this.windowsAclRunnerInvocation(), - '--workspace', policy.workspaceRoot, - // Explicit, never GetTempPathW-defaulted: the runner grants exactly - // this directory (workspace-write) or nothing (read-only). - '--temp', tmpdir(), - '--mode', policy.mode, - ] + case 'windows-acl': return this.windowsAclRunnerArgv(policy) default: return assertNever(runner) } } + /** + * The windows-acl runner argv for one policy. With a calling session + * (the policy's `sessionId`), the session's durable record is folded from + * the session log (provisioned on first use), its ACEs materialized once + * per server lifetime, and the runner receives `--write-sid` plus the + * session's PRIVATE temp subdirectory — it grants nothing and revokes + * nothing. Agentless calls (no session) pass no SID: the runner + * self-manages per-call grants on the ambient temp root. + * @param policy - the resolved per-call policy. + * @returns the runner invocation. + */ + private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { + const sessionId = policy.sessionId + const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) + if (record !== undefined) this.materializeAclGrant(record, policy.mode) + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Workspace-write sessions confine their temp writes to the PRIVATE + // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only + // and agentless runs pass the ambient temp root — the runner validates + // it exists but grants nothing (or self-manages, agentless only). + '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + '--mode', policy.mode, + ...record === undefined ? [] : ['--write-sid', record.writeSid], + ] + } + + /** + * Fold (or provision) the calling session's durable windows-acl record. + * The provision appends exactly one log-only `sandbox/acl-session` event + * to the session log; the record's workspace must equal the policy root — + * both derive from the session's immutable cwd, so a mismatch is a + * corrupted composition and fails loud. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's record. + */ + private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + const store = this.ctx.get('sessions') + if (store === undefined) { + throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') + } + const session = store.get(SessionId(sessionId)) + if (session === undefined) { + throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) + } + const existing = sessionAclRecord(session.events) + if (existing !== undefined) { + if (existing.workspace !== workspaceRoot) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} ` + + `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`, + ) + } + return existing + } + return provisionAclSession(session, workspaceRoot) + } + + /** + * Materialize the record's ACEs once per server lifetime: lazily at the + * session's first confined execution, reused for every later call (the map + * hit is the whole call). Workspace-write grants the workspace root and + * the private temp subdirectory (created here); read-only materializes + * NOTHING — its token alone restricts every write. Fail-closed: a + * half-materialized grant is revoked before the error propagates. + * @param record - the session's durable record. + * @param mode - the policy mode (grants exist only under workspace-write). + */ + private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { + if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return + const grant = AclWriteGrant.create(record.writeSid) + try { + mkdirSync(record.tempDir, { recursive: true }) + grant.add(record.workspace) + grant.add(record.tempDir) + } catch (error) { + // Revoke whatever stands and free the SID — never leave a half-grant + // behind a failed confine (the runner never runs). + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + } + throw error + } + this.aclGrants.set(record.writeSid, grant) + } + + /** + * Revoke every standing per-session grant and free every SID (provider + * dispose). Cleanup failures are reported, not thrown: cordis teardown + * must not be aborted by grant revocation, and the durable records make a + * missed revocation self-healing on the next resume. + */ + private revokeAclGrants(): void { + if (this.aclGrants.size === 0) return + const failures: unknown[] = [] + for (const grant of this.aclGrants.values()) { + try { + grant.dispose() + } catch (error) { + failures.push(error) + } + } + this.aclGrants.clear() + if (failures.length > 0) { + this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) + for (const error of failures) this.ctx.logger.warn(error) + } + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts new file mode 100644 index 0000000000..b39451d8ea --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -0,0 +1,275 @@ +/** + * The windows-acl per-session grant: the DURABLE record (session-log event + * fold/provision) plus the SERVER-LIFETIME ACE materialization + * ({@link AclWriteGrant}), exercised through the REAL + * LocalSandboxProvider.confine() with a real session store. The Win32 surface + * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), + * so these assertions run in every CI lane that runs sandbox-local's suites; + * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- + * acl's own tests on win32 hosts. + */ + +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { sessionTempDir } from '../src/acl-session.ts' + +/** Cross-file state shared with the vi.mock factory (hoisting contract). */ +const mockState = vi.hoisted(() => ({ + grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + addFailure: undefined as Error | undefined, + disposeFailure: undefined as Error | undefined, +})) + +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { + class MockAclWriteGrant { + readonly writeSid: string + readonly added: string[] = [] + disposed = false + constructor(writeSid: string) { + this.writeSid = writeSid + mockState.grants.push(this) + } + static create(writeSid: string): MockAclWriteGrant { + return new MockAclWriteGrant(writeSid) + } + add(path: string): void { + if (mockState.addFailure !== undefined) throw mockState.addFailure + this.added.push(path) + } + dispose(): void { + if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure + this.disposed = true + } + } + return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } +}) + +/** One provisioned record event, shaped like the live log's envelope. */ +function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { + return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + return { ctx, sandbox, fiber } +} + +/** A workspace root the policy, the record, and the session cwd all share. */ +function workspaceRoot(): string { + return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) +} + +describe('windows-acl per-session grant (LocalSandboxProvider)', () => { + const scratch: string[] = [] + + beforeEach(() => { + mockState.grants = [] + mockState.addFailure = undefined + mockState.disposeFailure = undefined + }) + + const cleanup = () => { + for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) + } + + it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-1') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', privateTemp, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) + expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + // Reuse: the SECOND confine is the map hit — no new grant, no new event. + const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(second.argv).toEqual(confined.argv) + expect(mockState.grants).toHaveLength(1) + expect(session.events).toHaveLength(1) + + // Provider dispose revokes the standing grant. + await fiber.dispose() + expect(mockState.grants[0]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-ro') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + + const confined = sandbox.confine(['true'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--mode', 'read-only', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + try { + const ws = workspaceRoot() + scratch.push(ws) + const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } + scratch.push(record.tempDir) + + const first = await setup() + const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) + // The constructor appends the `session/end-seed` marker, so the log is + // the seed plus that marker — exactly one acl record among them. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const confined = first.sandbox.confine(['true'], policy) + expect(confined.argv).toContain('S-1-4-77-1') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) + // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + expect(session.events).toHaveLength(2) + } finally { + cleanup() + } + }) + + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('fails loud without the session store, and when the policy names a session the store does not hold', async () => { + try { + const bare = new Context() + await bare.plugin(LocalSandboxProvider, {}) + const sandbox = bare.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) + + const { sandbox: withStore } = await setup() + expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/) + } finally { + cleanup() + } + }) + + it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + + // add() throws on the FIRST path: the cleanup dispose() runs and the + // original error propagates unchanged. + mockState.addFailure = new Error('grant exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + + // add() AND dispose() both throw: both surface as an AggregateError. + mockState.grants = [] + mockState.addFailure = new Error('grant exploded again') + mockState.disposeFailure = new Error('cleanup exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => { + try { + const { sandbox, fiber } = await setup() + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(0) + // Disposing a provider with no grants is a no-op (the empty-map guard). + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + + mockState.disposeFailure = new Error('revoke exploded') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) + } finally { + cleanup() + } + }) +}) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 608f0e9568..9effc139c3 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -23,6 +23,12 @@ { "path": "../sandbox" }, + { + "path": "../sandbox-windows-acl" + }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 4c87cbc8b3..3a3aac1d70 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + ...session === undefined ? {} : { sessionId: session.id }, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index c535847f58..11552f2a3a 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + sessionId: 'sess-first', }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + sessionId: 'sess-second', }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') @@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + sessionId: 'sess-symlink-parent', }) } finally { rmSync(root, { recursive: true, force: true }) @@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + sessionId: 'sess-approved', }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ad25c7e95f..875a1a441d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: d4f99c44f0542e9ed6e7d4605730ad0f0311a22c -README.zh.md: 9b51ccf7b29aefc3dea9a1c97bad75c2f41f5f98 +README.md: 1969515f6692eda059fd5e83449d68c6a4da5c4f +README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index d4f99c44f0..1969515f66 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -Modes: -- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. +Modes (the token's restricting-SID list follows the mode): +- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. +- `read-only` (list I = logon SID, Everyone, orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. ## Header verification @@ -56,9 +60,11 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. -- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. +- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -70,7 +76,9 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Each confined command mutates two directory DACLs** — a grant on entry and a revoke on exit, on the workspace root and the temp root: a handful of Win32 calls per command (inheritance is evaluated lazily per access, not a per-file walk). The runner pays this per command; reusing one grant per session is deferred work if the churn ever matters. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. +- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. +- **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9b51ccf7b2..4c751ba6dc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 ## 隔离 runner 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -模式: -- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 +模式(令牌的 restricting SID 列表随模式而定): +- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 +- `read-only`(列表 I = 登录 SID、Everyone、孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 ## 头文件查证 @@ -56,9 +60,11 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 +- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 ## 模型体验 @@ -70,7 +76,9 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ## 已知限制与后续工作 -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **每次受限命令都会改动两个目录的 DACL** —— 进入时授权、退出时撤销,分别作用在工作区根与临时根:每次命令若干次 Win32 调用(继承按访问惰性求值,不是逐文件遍历)。runner 按命令付这笔开销;按会话复用一次授权留作后续工作,待开销真的成为问题再实现。 +- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 +- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 +- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 7700474d5f..ef787cc410 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto' import { mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' -import { allocOverlapped, allocPtrSlot, decodePtr, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' @@ -26,6 +26,10 @@ import * as abi from './win32-abi.ts' * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which * removes every ACE for the trustee. + * @param sidPtr - the trustee SID the entry names. + * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). + * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @returns the packed entry buffer. */ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) @@ -174,13 +178,52 @@ function mergeAndApply( if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) } +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the + * orphan SID). Every field is read through koffi.decode at pointer offsets — + * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the + * ACE after the 4-byte mask — there is no pointer to read; reading one + * yields garbage addresses and crashed EqualSid, verified by gdb), so it is + * compared field-by-field against the orphan SID through bounded offset + * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" + * so the caller falls back to the merge-apply path, which owns the robust + * failure handling. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). + * @param sidPtr - the orphan write SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path + let offset = 8 // the first ACE follows the 8-byte ACL header + for (let index = 0; index < aceCount; index++) { + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); + // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path + const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false +} + /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID - * on `path`, inheriting to subcontainers and objects. Read-merge-write: the - * new ACE merges into the directory's CURRENT explicit DACL (same shape as - * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the - * per-path lock. The directory must be owned by the caller (owner implicit - * WRITE_DAC) — same precondition as the POC. + * on `path`, inheriting to subcontainers and objects. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE (the + * per-session grant surviving from a previous server lifetime), the + * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate + * the identical ACE across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write: the new ACE merges into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL gains the grant (the workspace or temp root). * @param sidPtr - the orphan write SID the ACE names. @@ -188,6 +231,14 @@ function mergeAndApply( export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { withPathLock(api, path, () => { const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { + // The exact ACE stands: releasing the descriptor is the whole operation. + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) + } + return + } mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 1d9665a8fc..dd0124edda 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -70,7 +70,6 @@ export interface Win32Bindings { localFree(memory: NativePtr): NativePtr // ---- SIDs ---------------------------------------------------------------- convertStringSidToSidW(stringSid: string, sid: NativePtr): number - convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number isValidSid(sid: NativePtr): number getLengthSid(sid: NativePtr): number @@ -111,6 +110,7 @@ export interface Win32Bindings { inheritHandles: number, creationFlags: number, environment: null, currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, ): number + setEnvironmentVariableW(name: string, value: string): number readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, buffer: null, size: number, @@ -219,16 +219,6 @@ export function decodeUint32(slot: NativePtr): number { return value as number } -/** - * Decode a UTF-16 string at a pointer. - * @param ptr - pointer to the NUL-terminated UTF-16 string. - * @returns the decoded string. - */ -export function decodeStr16(ptr: NativePtr): string { - const value: unknown = koffi.decode(ptr, 'str16') - return value as string -} - /** * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). * @param ptr - the koffi pointer. @@ -272,6 +262,69 @@ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { return value as NativePtr } +/** + * Decode a uint8 at a native pointer plus byte offset — the ACL walk's + * field-read primitive (koffi.decode with an offset, no memcpy, no pointer + * arithmetic). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint8. + */ +export function decodeUint8At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint8') + return value as number +} + +/** + * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint16. + */ +export function decodeUint16At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint16') + return value as number +} + +/** + * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint32. + */ +export function decodeUint32At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint32') + return value as number +} + +/** + * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, + * identifier authority, subauthorities up to the count) — never a fixed-size + * struct decode, which would read past a short SID allocation (a SID with + * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible + * subauthority count reads as unequal. + * @param left - pointer to one SID (offset 0). + * @param leftOffset - byte offset of the SID structure within `left`. + * @param right - pointer to the other SID. + * @param rightOffset - byte offset of the SID structure within `right`. + * @returns whether the SIDs are identical. + */ +export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { + const leftRevision = decodeUint8At(left, leftOffset) + const rightRevision = decodeUint8At(right, rightOffset) + if (leftRevision !== rightRevision) return false + const leftCount = decodeUint8At(left, leftOffset + 1) + const rightCount = decodeUint8At(right, rightOffset + 1) + if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false + for (let index = 0; index < 6; index++) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + } + for (let index = 0; index < leftCount; index++) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + } + return true +} + /** * Allocate a zeroed STARTUPINFOW. * @returns the allocated struct pointer. @@ -331,7 +384,6 @@ function bindings(): Win32Bindings { localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), - convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), @@ -356,6 +408,7 @@ function bindings(): Win32Bindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), @@ -378,6 +431,18 @@ export function win32(): Promise { return Promise.resolve(bindings()) } +/** + * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's + * server-side per-session grant materializes ACEs inside the synchronous + * `confine()` call, which cannot await. Same cached table as {@link win32} + * (the underlying koffi loads are synchronous; the async wrapper exists for + * the runner's await-shaped call sites). + * @returns the cached binding table. + */ +export function win32Sync(): Win32Bindings { + return bindings() +} + /** * Turn a Win32 error code into readable text via FormatMessageW. * @param api - the binding table. diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts new file mode 100644 index 0000000000..4a0f6f4dec --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -0,0 +1,95 @@ +/** + * Server-side per-session write grant: the ACE materialization half of the + * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE + * {@link AclWriteGrant} per session for the server process's lifetime — + * created lazily at the session's first confined execution, reused (never + * re-applied) for every later call, revoked on provider dispose. The durable + * half (the session's SID and paths surviving a restart) lives in the + * session log, owned by the seam; this module owns only the native half: the + * parsed SID pointer and the standing ACEs. + * + * Fail-closed: `add` throws on any grant failure and the caller disposes the + * instance (revoking every path granted so far); `dispose` revokes every + * standing grant and reports every cleanup failure. + * @module @deepseek-ai/dsh-sandbox-windows-acl/grant + */ + +import { grantWrite, revokeWrite } from './acl.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' + +/** + * One orphan write SID's server-lifetime grant materialization: the parsed + * SID pointer plus every directory whose DACL currently carries its ACE. + * Create with {@link AclWriteGrant.create}; dispose revokes all. + */ +export class AclWriteGrant { + /** The orphan write SID in SDDL string form. */ + readonly writeSid: string + private readonly api: Win32Bindings + private readonly sidPtr: NativePtr + private readonly grantedPaths: string[] = [] + + private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + this.api = api + this.sidPtr = sidPtr + this.writeSid = writeSid + } + + /** + * Parse the SID string and open the binding table (lazily, once per + * server). Fail-closed: any failure throws — nothing is granted yet. + * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param api - optional already-resolved bindings (tests). + * @returns the ready grant (no ACEs yet). + */ + static create(writeSid: string, api?: Win32Bindings): AclWriteGrant { + const bindings = api ?? win32Sync() + const sidSlot = allocPtrSlot() + if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) { + throwLastError(bindings, 'ConvertStringSidToSidW', writeSid) + } + const sidPtr = decodePtr(sidSlot) + if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) + return new AclWriteGrant(bindings, sidPtr, writeSid) + } + + /** + * Grant the write ACE on one directory (idempotent: an already-standing + * exact ACE skips the eager full-tree re-propagation — see + * {@link grantWrite}) and record the path for {@link dispose}. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. + * @param path - the directory whose DACL gains the grant. + */ + add(path: string): void { + grantWrite(this.api, path, this.sidPtr) + this.grantedPaths.push(path) + } + + /** Every directory currently carrying the grant, in grant order. */ + get paths(): readonly string[] { + return this.grantedPaths + } + + /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + dispose(): void { + const failures: unknown[] = [] + for (const path of this.grantedPaths) { + try { + revokeWrite(this.api, path, this.sidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = this.api.localFree(this.sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e312a40e09..1ff712f24e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -19,7 +19,10 @@ * - grants are standing ACE mutations on real directories — revoke them via * dispose() before the process exits (the POC's documented * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). + * this module's revoke instead). With `manageDacls: false` the CALLER owns + * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() + * skip grant/revoke entirely and the caller must not revoke under live + * children. * @module @deepseek-ai/dsh-sandbox-windows-acl */ @@ -36,6 +39,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' +export { AclWriteGrant } from './grant.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -50,6 +54,21 @@ export interface AclSandboxOptions { tempDir?: string | null /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ writeSid?: string + /** + * The file-effect mode this instance confines under — selects the + * restricted token's restricting-SID list (I for read-only, J for + * workspace-write) and MUST match the grant shape: read-only pairs with + * zero grants. The runner validates the argv-borne mode string at its + * boundary; this typed seam trusts the union. + */ + mode: 'read-only' | 'workspace-write' + /** + * Whether this instance owns its DACL grants (default true). False means + * the CALLER has already materialized the ACEs (the sandbox seam's + * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * the caller holds the grants for its own lifetime and revokes them. + */ + manageDacls?: boolean } /** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ @@ -84,7 +103,10 @@ export interface AclSandboxChild { wait(): Promise } -function randomWriteSid(): string { +/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). + * @returns the SDDL string form. + */ +export function randomWriteSid(): string { return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` } @@ -92,14 +114,18 @@ function randomWriteSid(): string { * One write-restricted sandbox instance: token + orphan-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes whatever was granted * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. + * failure. With `manageDacls: false` the caller owns the grants (per-session + * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string + /** The file-effect mode — the restricted token's restricting-SID list selection. */ + readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined + private readonly manageDacls: boolean private tempDirResolved: string | null | undefined private api: Win32Bindings | undefined private token: NativePtr | undefined @@ -107,6 +133,8 @@ export class AclSandbox { private grantedPaths: string[] = [] constructor(options: AclSandboxOptions) { + this.mode = options.mode + this.manageDacls = options.manageDacls ?? true this.writableDirs = options.writableDirs.map((directory) => { const absolute = resolve(directory) if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { @@ -149,9 +177,14 @@ export class AclSandbox { this.tempDirResolved = tempDir } - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - grantWrite(api, path, writeSidPtr) - this.grantedPaths.push(path) + // manageDacls: false — the caller (the sandbox seam's per-session grant) + // already materialized the ACEs; this instance must neither add nor + // remove any (its dispose() must not revoke the caller's standing grant). + if (this.manageDacls) { + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } } const logonSid = findLogonSid(api, currentToken) const restricted = createRestrictedToken( @@ -159,9 +192,8 @@ export class AclSandbox { { world: makeWellKnownSid(api, abi.WinWorldSid), authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), - interactive: makeWellKnownSid(api, abi.WinInteractiveSid), - local: makeWellKnownSid(api, abi.WinLocalSid), }, + this.mode, ) this.token = restricted if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') @@ -248,11 +280,13 @@ export class AclSandbox { const failures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) + if (this.manageDacls) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } } } try { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 578a615f84..a6be67fc3f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -8,13 +8,30 @@ * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , - * '--mode', , '--', ] + * '--mode', , + * ['--write-sid', ], '--', ] * * Modes: * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); documented in README. + * NUL device (`> $null` fails with access denied); the token's restricting + * list also drops Authenticated Users (CIM unavailable — documented in + * README). + * + * `--write-sid`: the seam's per-session grant contract — the CALLER has + * already materialized the orphan-SID ACEs (once per session, server + * lifetime) and owns their revocation, so the runner neither grants nor + * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) + * the runner self-manages grants per invocation as before. With + * `--write-sid` in workspace-write mode, the runner rewrites the TMP/TEMP + * entries of its OWN environment (SetEnvironmentVariableW) to the `--temp` + * directory — a PRIVATE per-session temp subdirectory the seam provisions + * (bwrap `--tmpfs /tmp` semantics) — and the child inherits the rewritten + * block (lpEnvironment NULL; an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW, verified empirically). + * Read-only leaves the ambient temp entries untouched (writes there are + * denied anyway). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -43,6 +60,7 @@ interface ParsedArgs { workspace: string temp: string mode: 'read-only' | 'workspace-write' + writeSid: string | undefined command: string args: string[] } @@ -51,6 +69,7 @@ function parseArgs(raw: string[]): ParsedArgs { let workspace: string | undefined let temp: string | undefined let mode: string | undefined + let writeSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -65,6 +84,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--workspace': workspace = value; break case '--temp': temp = value; break case '--mode': mode = value; break + case '--write-sid': writeSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -74,7 +94,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -101,9 +121,28 @@ async function main(): Promise { const sandbox = new AclSandbox({ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + mode: parsed.mode, + ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, + // With --write-sid the seam owns the DACLs (per-session grants): this + // invocation must neither add nor revoke ACEs. + manageDacls: parsed.writeSid === undefined, }) await sandbox.init() + // The seam's per-session temp contract: under --write-sid, workspace-write + // children see the PRIVATE per-session temp subdirectory through TMP/TEMP + // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment + // (SetEnvironmentVariableW) and the child inherits the block; self-managed + // and read-only runs keep the ambient entries. + if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { + if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + try { const child = sandbox.spawn({ command: parsed.command, diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 9c0436f29f..b71efe2ac7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -87,7 +87,11 @@ export interface SpawnedNative { /** * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends - * are returned for draining. + * are returned for draining. The child inherits the caller's environment block + * (lpEnvironment NULL); the caller rewrites entries through + * SetEnvironmentVariableW before spawning (the runner's per-session temp + * contract) — passing an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). * @param api - the binding table. * @param token - the restricted token the child runs under. * @param options - command, args, and working directory. diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 569c428551..009592bb28 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -105,22 +105,33 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { export interface RestrictingSidSet { world: NativePtr authUser: NativePtr - interactive: NativePtr - local: NativePtr } /** - * Create the write-restricted token. Ordering matters: EVERYONE first (the - * POC's note — the intersection check hits it on most objects), then the - * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan - * write SID that forms the write allowlist. S-1-2-1 (console logon) is - * intentionally absent: see win32-abi.ts for the verified failure modes. - * FAILS CLOSED: any failure throws — never spawn unrestricted. + * Create the write-restricted token with the mode-selected restricting list + * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant + * harness): + * - list I (read-only): [logon SID, EVERYONE, orphan] + * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * + * The logon SID and EVERYONE are shared: they keep the early startup chain + * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh + * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in + * list J ONLY because the CIM path's WMI namespace security check requires it + * (0x80041003 otherwise) — read-only drops it for a zero ambient-write + * surface (it closes the host's C:\-root tree-creation escape, where + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM + * unavailability; documented in README. INTERACTIVE/LOCAL are absent from + * BOTH lists — the host's Public tree grants write to INTERACTIVE, so + * removing it closes that escape. S-1-2-1 (console logon) is intentionally + * absent: see win32-abi.ts for the verified failure modes. FAILS CLOSED: any + * failure throws — never spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. * @param writeSid - the orphan SID forming the write allowlist. * @param known - the well-known SIDs entering the restricting list. + * @param mode - selects the restricting list (I for read-only, J for workspace-write). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -129,15 +140,11 @@ export function createRestrictedToken( logonSid: NativePtr, writeSid: NativePtr, known: RestrictingSidSet, + mode: 'read-only' | 'workspace-write', ): NativePtr { - const restrictingSids = buildRestrictingSids([ - known.world, - logonSid, - known.authUser, - known.interactive, - known.local, - writeSid, - ]) + const restrictingSids = buildRestrictingSids(mode === 'read-only' + ? [logonSid, known.world, writeSid] + : [logonSid, known.world, known.authUser, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 942862ceb9..b96acf4942 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -202,6 +202,15 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 /** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 +// ACE_HEADER.AceType (winnt.h lines ~3449-3463) +/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +export const ACCESS_ALLOWED_ACE_TYPE = 0 + +// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 +// #define SID_MAX_SUB_AUTHORITIES 15). +/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +export const SID_MAX_SUB_AUTHORITIES = 15 + // ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when // reading a DACL are marked with this bit and are not part of the explicit // DACL edits this module makes. diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index fe0bc13acc..2e4c5baab7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -12,7 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' @@ -148,11 +148,33 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) + it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + const api = await win32() + const dir = scratch() + const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const apply = vi.spyOn(api, 'setNamedSecurityInfoW') + try { + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + // The exact ACE now stands (the per-session grant surviving from a + // previous server lifetime): the second grant is a DACL read only. + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + const aces = readDirectAces(api, dir) + expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + revokeWrite(api, dir, orphanSid) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) + } finally { + apply.mockRestore() + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { const api = await win32() const dir = scratch() - const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1' }) - const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2' }) + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() sandboxA.dispose() @@ -216,7 +238,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { const api = await win32() const dir = scratch() - const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5' }) + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' }) try { await sandbox.init() const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts new file mode 100644 index 0000000000..fe803025b9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -0,0 +1,100 @@ +/** + * AclWriteGrant failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * dispose aggregates revocation and SID-free failures into an + * AggregateError. Pure stubs — no real Win32 calls, so these run on every + * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import koffi from 'koffi' + +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { AclWriteGrant } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ +function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { + const state = { failReads: false } + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 2), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, failReads: () => { state.failReads = true } } +} + +describe('AclWriteGrant failure paths', () => { + it('create fails closed: a SID parse failure throws before anything is granted', () => { + const api = { + convertStringSidToSidW: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-abc-1', api)).toThrow(/ConvertStringSidToSidW/) + }) + + it('create fails closed: a null SID pointer is rejected', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 0n) + return 1 + }), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/null SID/) + }) + + it('dispose aggregates a failing revocation into an AggregateError (best-effort cleanup)', () => { + const { api, failReads } = grantThenFailApi() + const grant = AclWriteGrant.create('S-1-4-42-42', api) + grant.add('C:\\granted') + expect(grant.paths).toEqual(['C:\\granted']) + failReads() + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) + + it('dispose aggregates a failing SID free into an AggregateError', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const grant = AclWriteGrant.create('S-1-4-42-42', api) + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts new file mode 100644 index 0000000000..02090e2fe9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -0,0 +1,69 @@ +/** + * AclWriteGrant tests: the server-side per-session grant materialization — + * SID parsing fail-closed, ACE add/dispose round-trip against the REAL + * directory DACL (observed through icacls, the operator's own tool), and + * the recorded path order. Win32-only, like the other real-FFI suites. + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { AclWriteGrant } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +/** The directory DACL as icacls renders it (the operator-visible form). */ +function icaclsText(path: string): string { + const result = spawnSync('icacls', [path], { encoding: 'utf8' }) + expect(result.status, `icacls failed: ${result.stderr}`).toBe(0) + return result.stdout +} + +describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-grant-')) + scratchDirs.push(dir) + return dir + } + + it('create parses the SID fail-closed: a malformed SID throws before anything is granted', () => { + expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) + }) + + it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + const dir = scratch() + const grant = AclWriteGrant.create('S-1-4-9000-77') + grant.add(dir) + expect(grant.paths).toEqual([dir]) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + // A second add over the standing exact ACE is a DACL-read no-op: the + // grant stays exactly one ACE (per-session reuse after a restart). + grant.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + grant.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + }) + + it('two grants with different SIDs coexist and revoke independently', () => { + const dir = scratch() + const grantA = AclWriteGrant.create('S-1-4-9000-78') + const grantB = AclWriteGrant.create('S-1-4-9000-79') + grantA.add(dir) + grantB.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantA.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantB.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-79') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index e370316b79..0eb78e16f4 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // block, which host runtimes (vitest worker pools) may not keep in sync // with process.env — and a real-temp grant would inherit over every // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, mode: 'workspace-write' }) await sandbox.init() }) @@ -91,7 +91,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 680d65b253..8ddd10c474 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { AclWriteGrant } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -59,7 +60,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List J carries Authenticated Users: the CIM path (WMI namespace + // security check) stays alive under workspace-write. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', @@ -70,11 +74,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: OK') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -84,7 +89,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List I drops Authenticated Users: the WMI namespace security check + // fails (0x80041003) — the documented read-only CIM boundary, the + // price of the zero ambient-write surface. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', @@ -96,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('NUL-WRITE: DENIED') expect(result.stdout).toContain('DOLLAR-NULL: OK') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) }, 30_000) @@ -124,6 +134,40 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) + it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { + const writeSid = 'S-1-4-9000-99' + const privateTemp = join(isolatedTemp, 'private-subdir') + mkdirSync(privateTemp) + const grant = AclWriteGrant.create(writeSid) + grant.add(privateTemp) + try { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, + "'TEMP-ENV: ' + $env:TEMP;", + "'TMP-ENV: ' + $env:TMP", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + // The runner granted nothing (only the caller's private-temp grant + // stands): the workspace write is denied, the private temp write lands, + // and the child's TMP/TEMP point at the private subdirectory. + expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') + expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') + expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) + expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) + expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) + } finally { + grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index aeca1ba8d6..6170482a37 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,6 +40,13 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } /**