From 6478da61e3cceccc626ccf00832b8d64bc387c9b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 23:23:38 +0800 Subject: [PATCH] fix(sandbox): harden the per-session record and the ACL runner failure paths (review round v6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 6 +- docs/core-data-structures/sandbox.zh.md | 6 +- docs/module-graph.md | 131 ++++---- docs/persistence-catalog.md | 10 +- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 7 + packages/bash/tool-pwsh/src/index.ts | 6 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../sandbox/sandbox-local/src/acl-session.ts | 92 ++++-- packages/sandbox/sandbox-local/src/index.ts | 94 ++++-- .../sandbox-local/tests/acl-session.spec.ts | 294 +++++++++++------- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 7 +- .../sandbox/sandbox-windows-acl/README.zh.md | 7 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 4 + .../sandbox/sandbox-windows-acl/src/index.ts | 10 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 4 + .../tests/failure-paths.spec.ts | 26 ++ .../tests/provider-chain.spec.ts | 4 +- packages/sandbox/sandbox/package.json | 2 + packages/sandbox/sandbox/src/index.ts | 7 +- packages/sandbox/sandbox/tsconfig.json | 3 + 27 files changed, 472 insertions(+), 278 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 8b534a3267..c9e3414e4d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 +2026-08-08-windows-acl-restricted-token-sandbox.md: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 62b9613adf..6c358c1bdc 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 95c2604d6a..028b7dedf5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index abebea09b5..ca1ea8fcaa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:156`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index dd31511e20..a759b15b74 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da -sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c +sandbox.md: 75e9a0a9b1eb85a5c097f2c583a1fdf94da62305 +sandbox.zh.md: df2dae84f43d5e64bb11418012bdcbd196f028f2 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index a9a1fec080..75e9a0a9b1 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 99505265a9..df2dae84f4 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 477399d72d..5194a1f5b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -413,8 +413,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -425,13 +423,6 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_agent --> pkg_type_meta - pkg_bash --> pkg_invariants - pkg_bash --> pkg_sandbox - pkg_bash --> pkg_subprocess - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants @@ -500,10 +491,9 @@ flowchart TD pkg_lsp_local --> pkg_lsp pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox - pkg_sandbox_local --> pkg_session + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -524,22 +514,13 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_type_meta - pkg_bash_local --> pkg_bash - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -548,9 +529,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -597,10 +575,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -614,6 +588,10 @@ flowchart TD pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -660,21 +638,22 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_bash - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants @@ -683,6 +662,9 @@ flowchart TD pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> pkg_session pkg_compact_tool_result_prune --> pkg_token_meter + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -720,6 +702,10 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -758,6 +744,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1230,11 +1231,8 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1252,19 +1250,15 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1276,10 +1270,10 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1290,17 +1284,21 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1308,6 +1306,9 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9b396d98b8..d64524c675 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -501,13 +501,15 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -515,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 9e4aa04546..d710801004 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -129,6 +129,13 @@ describe('helpers (pure)', () => { expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() }) + + it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => { + const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }] + expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules)) + .toEqual({ detail: 'windows-acl-run: missing --workspace' }) + }) }) describe('matchesSignature', () => { diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index e93b246469..fb07683c96 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,6 +114,12 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base + // The CLM contract below is Windows-restricted-token behavior, but the gate + // is 'any confining executor is mounted' (escalationModes non-empty). The + // conflation is safe today because every shipped composition pairing + // tool-pwsh with a confining executor is win32-only; a future POSIX + // pwsh-sandbox composition must gate the CLM sentence on the platform + // instead (tracked in the pwsh-tool-and-executor Agent Note). return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 286020dfcd..62f85bab96 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: SessionId;\n}', }, { name: 'SandboxMode', diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index c820694b11..5247601250 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -8,18 +8,20 @@ * ({@link AclWriteGrant} materialization, revoked on dispose); the record * survives restarts so a resumed session reuses the SAME SID — re-granting * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a - * fresh record; the record's workspace must match the session's immutable - * cwd (asserted by the provider). + * fresh dead SID's ACEs per restart. The record is BOUND to its owning + * session id, so a fork (which copies the parent's events, record included) + * never inherits the parent's identity — it provisions a fresh one. The + * record's payload is durable input and is validated at the fold (orphan-SID + * shape, well-formed temp path); a matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ -import { createHash } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -27,13 +29,15 @@ declare module '@deepseek-ai/dsh-session' { * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -46,48 +50,73 @@ declare module '@deepseek-ai/dsh-session' { export interface AclSessionRecord { /** The orphan write SID whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session id (binds the record against fork inheritance). */ + sessionId: SessionId /** The workspace root the record was provisioned for. */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } +/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ +const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u + /** - * The session's windows-acl record: the last `sandbox/acl-session` event in - * the log, or undefined when the session has none (never confined on - * Windows). The pure fold — resume needs no catch-up machinery because - * replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the last provisioned record, or undefined without one. + * The session's record: the last `sandbox/acl-session` event owned by it, or + * undefined (never confined / a fork). Durable-input validation: tampered + * SID or temp path fails loud. @param events/@param sessionId/@returns as + * below. + * @param events - session events (other types skipped). + * @param sessionId - owning session (fork binding). + * @returns the last owned record, or undefined without one. */ -export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { +export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] as SessionEvent - if (event.type === 'sandbox/acl-session') return event.data + if (event.type !== 'sandbox/acl-session') continue + const data = event.data + // Fork copies the parent's record: skip non-owned records (fork mints fresh). + if (data.sessionId !== sessionId) continue + if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` + + '(expected the orphan shape S-1-4-x-y)', + ) + } + if (typeof data.workspace !== 'string' || data.workspace.length === 0) { + throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) + } + if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`, + ) + } + return data } return undefined } /** - * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges - * across server restarts (the same SID re-grants the same directory) and OS - * temp hygiene may reclaim it — deliberately no GC here. - * @param sessionId - the session identity. + * The session's private temp subdirectory name: `\dsh-<16 random hex>`. + * The name is RANDOM and persisted in the record — convergence across server + * restarts comes from the record (the same SID re-grants the same directory), + * not from any derivation an attacker (who knows the session id through + * `DSH_SESSION_ID`) could predict and pre-place. The provider creates it + * exclusively and rejects reparse points; OS temp hygiene may reclaim it — + * deliberately no GC here. * @returns the private temp subdirectory path. */ -export function sessionTempDir(sessionId: string): string { - const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) - return join(tmpdir(), `dsh-${digest}`) +export function sessionTempDir(): string { + return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`) } /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp - * subdirectory, appended as exactly one log-only `sandbox/acl-session` - * event — the provision IS its event, nothing mutates record state out of - * band. Fork (new session id) provisions a fresh record; resume replays the - * stored one. + * confined execution): a fresh write SID plus the private temp subdirectory, + * appended as exactly one log-only `sandbox/acl-session` event — the + * provision IS its event, nothing mutates record state out of band. Fork + * (whose copied parent record is not its own) provisions a fresh record; + * resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. @@ -95,8 +124,9 @@ export function sessionTempDir(sessionId: string): string { export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { writeSid: randomWriteSid(), + sessionId: session.id, workspace: workspaceRoot, - tempDir: sessionTempDir(session.id), + tempDir: sessionTempDir(), } session.append('sandbox/acl-session', record) return record diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 93b92de0a0..e18d68449a 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -29,7 +29,7 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' @@ -163,8 +163,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // The restricted token intersects every write access by construction, so - // the ACL runner governs every promised file effect — full enforcement. + // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists + // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are + // absent from both — pinned by the runner's Public-probe and CIM-denial + // regressions). FAT-class (non-ACL) targets are declared unsupported + // (warn-only) in the backend README — outside the promise, not an + // exception to it. 'windows-acl': 'full', } @@ -194,13 +198,19 @@ const DENIAL_SIGNATURES = { runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record +/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */ +const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127 + /** * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. * The windows-acl runner prints `windows-acl-run: ` on every - * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * runner-side failure and exits 127 — the rule is exit-gated on that status + * so a confined command that merely PRINTS the signature (or a runner + * cleanup failure reported on a non-zero child exit) is never misclassified + * as "the command did not run". Keep the Landlock tuple aligned with the * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ @@ -212,7 +222,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], - 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], + 'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -319,48 +329,63 @@ export class LocalSandboxProvider extends SandboxProvider { * the session log (provisioned on first use), its ACEs materialized once * per server lifetime, and the runner receives `--write-sid` plus the * session's PRIVATE temp subdirectory — it grants nothing and revokes - * nothing. Agentless calls (no session) pass no SID: the runner - * self-manages per-call grants on the ambient temp root. + * nothing. A fresh provision kicks an IMMEDIATE persistence flush right + * after the append (no write-behind debounce delay), narrowing the + * crash-and-lose-record window to the flush latency itself — the residual + * is documented in the README (the spawn seams are synchronous, so no + * await barrier exists between record and ACEs). Agentless calls (no + * session) pass no SID: the runner self-manages per-call grants on the + * ambient temp root. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) - if (record !== undefined) this.materializeAclGrant(record, policy.mode) + if (sessionId === undefined) { + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + '--temp', tmpdir(), + '--mode', policy.mode, + ] + } + const record = this.aclSessionRecord(sessionId, policy.workspaceRoot) + this.materializeAclGrant(record, policy.mode) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, // Workspace-write sessions confine their temp writes to the PRIVATE // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // and agentless runs pass the ambient temp root — the runner validates - // it exists but grants nothing (or self-manages, agentless only). - '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + // runs pass the ambient temp root — the runner validates it exists + // but grants nothing. + '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), '--mode', policy.mode, - ...record === undefined ? [] : ['--write-sid', record.writeSid], + '--write-sid', record.writeSid, ] } /** * Fold (or provision) the calling session's durable windows-acl record. * The provision appends exactly one log-only `sandbox/acl-session` event - * to the session log; the record's workspace must equal the policy root — - * both derive from the session's immutable cwd, so a mismatch is a - * corrupted composition and fails loud. + * to the session log and kicks an immediate persistence flush (the + * write-behind coordinator's bounded window would otherwise delay the + * record's durability past its ACE materialization); the record's + * workspace must equal the policy root — both derive from the session's + * immutable cwd, so a mismatch is a corrupted composition and fails loud. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. * @returns the session's record. */ - private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord { const store = this.ctx.get('sessions') if (store === undefined) { throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') } - const session = store.get(SessionId(sessionId)) + const session = store.get(sessionId) if (session === undefined) { throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) } - const existing = sessionAclRecord(session.events) + const existing = sessionAclRecord(session.events, sessionId) if (existing !== undefined) { if (existing.workspace !== workspaceRoot) { throw new Error( @@ -370,20 +395,30 @@ export class LocalSandboxProvider extends SandboxProvider { } return existing } - return provisionAclSession(session, workspaceRoot) + const record = provisionAclSession(session, workspaceRoot) + // Immediate durability kick: the append is write-behind (bounded + // coordinator window); flush now so the record is durable as close to + // its ACE materialization as the synchronous confine seam allows. The + // residual window (a crash inside the flush latency) can strand inert + // orphan-SID ACEs — documented in the README. + void store.flush(session) + return record } /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory (created here); read-only materializes - * NOTHING — its token alone restricts every write, and a standing grant - * from an earlier workspace-write period is KEPT through a downgrade - * (never revoked): the read-only restricted token carries no orphan SID - * (the read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * grant is revoked before the error propagates. + * the private temp subdirectory — created here EXCLUSIVELY (the name is + * random and unguessable, a pre-existing entry throws EEXIST, and a + * reparse point is rejected, so the grant never lands on an + * attacker-placed object); read-only materializes NOTHING — its token + * alone restricts every write, and a standing grant from an earlier + * workspace-write period is KEPT through a downgrade (never revoked): the + * read-only restricted token carries no orphan SID (the read-only list), + * so the ACE is inert there, while the map hit keeps the re-upgrade free + * of re-propagation. Fail-closed: a half-materialized grant is revoked + * before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ @@ -391,7 +426,10 @@ export class LocalSandboxProvider extends SandboxProvider { if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return const grant = AclWriteGrant.create(record.writeSid) try { - mkdirSync(record.tempDir, { recursive: true }) + // Exclusive creation (no `recursive`): a pre-existing entry OR a + // reparse point both fail EEXIST — the grant never lands on a foreign + // object. + mkdirSync(record.tempDir) grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index d1cca979a0..a0e7a38390 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,22 +1,19 @@ /** - * The windows-acl per-session grant: the DURABLE record (session-log event - * fold/provision) plus the SERVER-LIFETIME ACE materialization - * ({@link AclWriteGrant}), exercised through the REAL - * LocalSandboxProvider.confine() with a real session store. The Win32 surface - * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), - * so these assertions run in every CI lane that runs sandbox-local's suites; - * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- - * acl's own tests on win32 hosts. + * windows-acl per-session grant: the DURABLE record (log-event fold/provision + * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization, through the REAL LocalSandboxProvider.confine() with a + * real session store. Win32 surface mocked at the package boundary; the + * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { sessionTempDir } from '../src/acl-session.ts' @@ -52,7 +49,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { }) /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -70,6 +67,11 @@ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) } +/** A well-shaped private temp path under the host temp root (never created). */ +function shapedTempPath(): string { + return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) +} + describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const scratch: string[] = [] @@ -83,38 +85,30 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-1') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(confined.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'pwsh', '/Command', 'x', - ]) + expect(confined.argv).toContain('--write-sid') + expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain('workspace-write') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir + scratch.push(tempDir) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + expect(existsSync(tempDir)).toBe(true) // created exclusively expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the SECOND confine is the map hit — no new grant, no new event. - const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(second.argv).toEqual(confined.argv) + // Reuse: the second confine is the map hit. + sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(mockState.grants).toHaveLength(1) expect(session.events).toHaveLength(1) - // Provider dispose revokes the standing grant. await fiber.dispose() expect(mockState.grants[0]!.disposed).toBe(true) } finally { @@ -122,15 +116,60 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + + // read-only first: record rides along, nothing materialized, ambient temp. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toContain('--write-sid') + expect(confinedRo.argv).toContain(tmpdir()) + expect(mockState.grants).toHaveLength(0) + const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string } + scratch.push(record.tempDir) + expect(existsSync(record.tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the same SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', record.tempDir, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + expect(existsSync(record.tempDir)).toBe(true) + + // Reuse: map hit. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(1) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-ro') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') } const confined = sandbox.confine(['true'], policy) expect(confined.argv).toEqual([ @@ -143,69 +182,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - } finally { - cleanup() - } - }) - - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const privateTemp = sessionTempDir('sess-switch') - scratch.push(privateTemp) - const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' } - - // Read-only first: the record still rides along (--write-sid, one - // event) but NOTHING is materialized and the ambient temp root is - // passed — the map stays empty, so the later upgrade must materialize. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tmpdir(), - '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) - - // Upgrade: the FIRST workspace-write confine materializes the grant - // (the map was empty — read-only never wrote it) with the SAME SID - // and the private temp dir, so the upgrade path cannot dead-end. - const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) - - // Reuse: the second workspace-write call is the map hit. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) - - // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list carries no - // orphan SID (pinned by the windows-acl runner regression), so the - // ACE is inert under read-only while the map hit keeps the - // re-upgrade free of eager propagation. - sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { cleanup() @@ -216,21 +192,20 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { try { const ws = workspaceRoot() scratch.push(ws) - const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } - scratch.push(record.tempDir) + const tempDir = shapedTempPath() + const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + scratch.push(tempDir) const first = await setup() const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) - // The constructor appends the `session/end-seed` marker, so the log is - // the seed plus that marker — exactly one acl record among them. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) expect(confined.argv).toContain('S-1-4-77-1') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) - // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) } finally { @@ -238,14 +213,93 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const parentTemp = shapedTempPath() + const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + scratch.push(parentTemp) + // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. + const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh + } finally { + cleanup() + } + }) + + it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // writeSid = Everyone: would widen the grant to every token. + const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } + ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) + const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } + expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) + expect(mockState.grants).toHaveLength(0) + + // tempDir outside the host temp root. + const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) + const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } + expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. + const preexisting = shapedTempPath() + mkdirSync(preexisting) + scratch.push(preexisting) + const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) + const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } + expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + + // Reparse point: same EEXIST (exclusive mkdir never follows links). + const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) + scratch.push(target) + const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name + symlinkSync(target, linkPath) + scratch.push(linkPath) + const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) + const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } + expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) expect(mockState.grants).toHaveLength(0) } finally { @@ -259,7 +313,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { await bare.plugin(LocalSandboxProvider, {}) const sandbox = bare.sandbox as LocalSandboxProvider sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') } expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) const { sandbox: withStore } = await setup() @@ -274,17 +328,17 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: the cleanup dispose() runs and the - // original error propagates unchanged. + // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) - // add() AND dispose() both throw: both surface as an AggregateError. + // add() AND dispose() both throw: AggregateError. mockState.grants = [] mockState.addFailure = new Error('grant exploded again') mockState.disposeFailure = new Error('cleanup exploded') @@ -308,7 +362,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toHaveLength(0) - // Disposing a provider with no grants is a no-op (the empty-map guard). await fiber.dispose() } finally { cleanup() @@ -320,9 +373,10 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) mockState.disposeFailure = new Error('revoke exploded') @@ -334,4 +388,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { cleanup() } }) + + it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => { + const a = basename(sessionTempDir()) + const b = basename(sessionTempDir()) + expect(a).not.toBe(b) + expect(a).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(b).toMatch(/^dsh-[0-9a-f]{16}$/) + }) }) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index e97ea17491..1f15765335 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -383,7 +383,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) it('reads a failing probe as unusable and walks to the next rung', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 834cebbe3f..3fb0ec6692 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 -README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e +README.md: 80d0502c3ace71da49e8f40efc6aa379286c74ac +README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index e9309caa87..80d0502c3a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. @@ -64,7 +64,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. - **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. @@ -80,6 +80,7 @@ None directly; the denial surface belongs to the tool layer. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eabf8f1c1e..b0c50af1b3 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 @@ -64,7 +64,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 - **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 @@ -80,6 +80,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index dd0124edda..309fe8fe50 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -123,6 +123,9 @@ export interface Win32Bindings { createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // Terminate a suspended child that could not be placed in the kill-on-close + // job — closing handles alone would leave it hanging forever. + terminateProcess(process: NativePtr, exitCode: number): number // ---- console ------------------------------------------------------------- // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): // the runner survives console Ctrl+C so the child handles its own and the @@ -417,6 +420,7 @@ function bindings(): Win32Bindings { createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32Bindings diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index f22dadfbb0..93e0e33c69 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -5,8 +5,14 @@ * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only * this sandbox instance adds to the target directories' DACLs — the * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else. Unlike the POC, every API failure throws with the - * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * ACE, and nowhere else the orphan SID is concerned (the token's write check + * ALSO inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, + * INTERACTIVE, and LOCAL are absent from both lists — see the seam's + * dual-list contract in `packages/sandbox/sandbox-local` and the package + * README's Modes section for the complete boundary). Unlike the POC, every + * API failure throws with the API name and exact Win32 code; a child is + * NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): * - writes are restricted; reads, network, and process visibility are NOT diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index b71efe2ac7..eafcb252ce 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -331,7 +331,11 @@ export function spawnSandboxedInherited( } if (api.assignProcessToJobObject(job, processHandle) === 0) { + // The child was created suspended and is NOT in the kill-on-close job: + // closing handles would leave it suspended forever. Terminate it first, + // then drop the handles and throw. const win32Code = api.getLastError() + api.terminateProcess(processHandle, 1) api.closeHandle(threadHandle) api.closeHandle(processHandle) api.closeHandle(job) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 14f370602c..0c1d7f42b3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -102,6 +102,32 @@ describe('spawn failure paths close their handles', () => { expect(closeHandle).toHaveBeenCalledTimes(3) expect(closed).toEqual([201n, 200n, 100n]) }) + + it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { + // The child is created suspended and is NOT in the kill-on-close job when + // the assignment fails: closing the job cannot kill it, so the failure + // branch must TerminateProcess first or every failure strands a hanging + // orphan forever. + const { api: baseApi, closeHandle } = resumeFailureApi() + type JobFailureApi = Win32Bindings & { + assignProcessToJobObject: ReturnType + terminateProcess: ReturnType + } + const api = baseApi as JobFailureApi + api.assignProcessToJobObject = vi.fn(() => 0) + api.terminateProcess = vi.fn(() => 1) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') + expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) + // thread, process, job — and the child is already dead before they close. + expect(closeHandle).toHaveBeenCalledTimes(3) + }) }) describe('getTempPath buffer defense', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index f2737948ca..2557b1f43b 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -43,7 +43,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { ]) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) // A sole candidate is selected unprobed. expect(probeWindowsAcl).not.toHaveBeenCalled() }) @@ -53,6 +53,6 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { const confined = sandbox.confine(['true'], RO) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) }) diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index deb703dc15..7c91afe76b 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -27,11 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index fee9164ead..6c2397a2e0 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, @@ -41,12 +42,12 @@ export interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } /** diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index af4de1c016..673ee51547 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" }