fix(sandbox): limit Windows extra directory grants to file tools
This commit is contained in:
1 parent
6d1156df70
commit
41a1aa8a97
56 files changed
+569
-797
No files matched your search
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 2a32db1f27371403
|
||||
zh: 89cf04705ef66769
|
||||
/agent-note-session-writable-directory-effects/decision:
|
||||
en: 1ce0f8414a0f9db1
|
||||
zh: 0d2da6cf8ac2adbb
|
||||
en: af67969841bedb67
|
||||
zh: edabdfaa30bcadd1
|
||||
/agent-note-session-writable-directory-effects/alternatives-considered:
|
||||
en: dad77744dea17bde
|
||||
zh: bb3838f9f9099944
|
||||
/agent-note-session-writable-directory-effects/consequences:
|
||||
en: 008f7270b7d3aaaf
|
||||
zh: 2b60ed4c4739ff75
|
||||
en: 1c2468df41bfbb46
|
||||
zh: 2118230c0091b20e
|
||||
+1
-3
@@ -16,8 +16,6 @@ Registration disposal affects subsequent operations, not already-resolved calls
|
||||
|
||||
Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules.
|
||||
|
||||
Windows standing ACL grants follow the [per-directory identity decision](2026-10-28-windows-directory-write-identities.md): each new execution carries only its currently permitted directory SIDs, while private temp capabilities remain separate.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service.
|
||||
@@ -28,4 +26,4 @@ Windows standing ACL grants follow the [per-directory identity decision](2026-10
|
||||
|
||||
## Consequences
|
||||
|
||||
Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
|
||||
Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocesses ignore extra roots while file tools enforce them; the [Windows scope decision](2026-10-29-windows-file-tool-directory-grants.md) records this limitation. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
|
||||
+1
-3
@@ -16,8 +16,6 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
|
||||
|
||||
不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。
|
||||
|
||||
Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-directory-write-identities.zh.md):每次新执行仅携带当前允许的目录 SID,私有临时能力继续独立。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。
|
||||
@@ -28,4 +26,4 @@ Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-direct
|
||||
|
||||
## 影响
|
||||
|
||||
同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
|
||||
同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程忽略额外根目录,文件工具则执行这些授权;[Windows 范围决策](2026-10-29-windows-file-tool-directory-grants.zh.md)记录此限制。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-10-28-windows-directory-write-identities.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
|
||||
/agent-note-windows-per-directory-write-identities:
|
||||
en: df2c97b00cabb275
|
||||
zh: e09a61baf823ff5e
|
||||
/agent-note-windows-per-directory-write-identities/problem:
|
||||
en: ce436e11d96c3c96
|
||||
zh: dcda6f85413a5be6
|
||||
/agent-note-windows-per-directory-write-identities/decision:
|
||||
en: 2b778019f042b4c7
|
||||
zh: 6477f4b8a4d05cab
|
||||
/agent-note-windows-per-directory-write-identities/alternatives-considered:
|
||||
en: d87e2cf9196d66e7
|
||||
zh: 04261fa61efe407b
|
||||
/agent-note-windows-per-directory-write-identities/consequences:
|
||||
en: f20026b54351fcb4
|
||||
zh: 350ff60897c1f4bd
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Windows per-directory write identities
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-28-windows-directory-write-identities.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Session-scoped writable roots](2026-10-26-session-writable-directory-effects.md) let independent plugins add directories to an execution's policy. A SID derived from the complete directory set requires additional standing ACEs and tree propagation on unchanged directories whenever that combination changes.
|
||||
|
||||
## Decision
|
||||
|
||||
Each canonical writable directory has one deterministic SID, granted only to that directory. Each new execution carries the SIDs for all its permitted directories. The provider caches grants by directory and rechecks their ACEs before execution so directory recreation does not invalidate the cache. Shared directories reuse the same standing grant across Sessions and permission combinations; a Session without the directory's SID receives no authority from that grant.
|
||||
|
||||
`AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots.
|
||||
|
||||
Directory grants compare supplied and physical paths with Node’s host-specific `path.relative`, while SID derivation retains the physical spelling. This accepts ordinary Windows casing differences without adding a component-walking implementation. The comparison assumes case-insensitive Windows paths; legacy 8.3 aliases remain unsupported. This deliberately favors the common path syntax over exhaustive filesystem-identity handling.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse.
|
||||
|
||||
**One workspace SID extended to extra directories.** This accumulates authority for processes that already hold the workspace SID and for other Sessions using that workspace. Adding a directory must select another existing directory capability, not expand a shared capability's meaning.
|
||||
|
||||
## Consequences
|
||||
|
||||
Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The [Windows ACL decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) retains the restricted-token mechanism and hard-link limitations; this decision replaces its single-workspace identity model.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Windows 逐目录写入身份
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-28-windows-directory-write-identities.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[会话级可写根目录](2026-10-26-session-writable-directory-effects.zh.md)允许独立插件向执行策略添加目录。如果 SID 由完整目录集合派生,每次组合变化都需要向未变化的目录添加常驻 ACE,并重新传播整棵目录树。
|
||||
|
||||
## 决策
|
||||
|
||||
每个规范可写目录拥有一个确定性 SID,仅在该目录上授予它。每次新执行携带全部允许目录的 SID。提供方按目录缓存授权,并在执行前重新检查 ACE,避免目录重建使缓存失效。共享目录在不同 Session 和权限组合之间复用同一个常驻授权;未携带该目录 SID 的 Session 无法从该授权获得权限。
|
||||
|
||||
`AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。
|
||||
|
||||
目录授权使用 Node 按主机平台实现的 `path.relative` 比较传入路径与物理路径,SID 派生则保留物理路径的拼写。这接受普通 Windows 大小写差异,无需加入逐级遍历路径的实现。比较假定 Windows 路径不区分大小写;旧式 8.3 别名仍不受支持。这里有意优先支持常见路径语法,而不穷尽文件系统身份处理。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。
|
||||
|
||||
**将工作区 SID 扩展到额外目录。** 这会为已经持有工作区 SID 的进程及使用该工作区的其他 Session 累积权限。添加目录必须选择另一个已有目录能力,而不能扩大共享能力的含义。
|
||||
|
||||
## 影响
|
||||
|
||||
已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。[Windows ACL 决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)保留受限令牌机制与硬链接限制;本决策替换其单工作区身份模型。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-29-windows-file-tool-directory-grants.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md
|
||||
/agent-note-windows-additional-directory-grants-stay-in-file-tools:
|
||||
en: aea9858c74dfd36e
|
||||
zh: 4064573838d6000f
|
||||
/agent-note-windows-additional-directory-grants-stay-in-file-tools/problem:
|
||||
en: da14bde7ed3eae49
|
||||
zh: 6aad2181b2756b50
|
||||
/agent-note-windows-additional-directory-grants-stay-in-file-tools/decision:
|
||||
en: acec40cda6036f2c
|
||||
zh: 149c9eecb9863643
|
||||
/agent-note-windows-additional-directory-grants-stay-in-file-tools/alternatives-considered:
|
||||
en: 09d6cceef8004a65
|
||||
zh: e9009eb5bbae1d87
|
||||
/agent-note-windows-additional-directory-grants-stay-in-file-tools/consequences:
|
||||
en: a37925f508bb5644
|
||||
zh: f5a6cb4e099f94c6
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Windows additional directory grants stay in file tools
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-29-windows-file-tool-directory-grants.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Memory plugins need session-specific writes to directories such as `~/.dsh/memory`. Extending Windows process access through ACLs requires managing persistent filesystem grants and token identities for changing directory combinations, increasing reliance on a backend with existing enforcement limitations.
|
||||
|
||||
## Decision
|
||||
|
||||
On Windows, the [session directory effects](2026-10-26-session-writable-directory-effects.md) authorize extra directories only through the harness filesystem checks used by `write` and `edit`. The process provider ignores extra roots and retains its workspace and private-temp permissions. The ACL package, runner arguments, and workspace SID remain unchanged. POSIX process backends continue to honor extra roots.
|
||||
|
||||
Windows process support for extra roots is deferred until a better sandbox backend can enforce it without extending the ACL customization. Model context explicitly distinguishes file-tool grants from Windows process permissions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One SID per directory or directory set.** These designs could select process authority for each execution, but require more ACL mutation, identity management, and lifecycle handling. The capability does not justify expanding this mechanism.
|
||||
|
||||
**Reject processes whenever extra grants exist.** This prevents unrelated PowerShell and workspace operations from running when a memory plugin registers a directory, even if the command never accesses it.
|
||||
|
||||
## Consequences
|
||||
|
||||
A Windows memory plugin can use `write` and `edit` for its registered directory while ordinary workspace commands continue to work. A shell command or PTC program cannot use that extra grant. This deliberately accepts different capabilities across file tools and process tools; registration and disposal remain session-scoped, and existing processes retain their launch-time permissions.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Windows 额外目录授权仅用于文件工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-29-windows-file-tool-directory-grants.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
记忆插件需要针对会话向 `~/.dsh/memory` 等目录写入。通过 ACL 扩展 Windows 进程访问权限,需要为不断变化的目录组合管理持久文件系统授权与令牌身份,从而加深对已有执行限制的后端的依赖。
|
||||
|
||||
## 决策
|
||||
|
||||
在 Windows 上,[会话目录 effect](2026-10-26-session-writable-directory-effects.zh.md) 仅通过 `write` 与 `edit` 使用的 Harness 文件系统检查授权额外目录。进程提供方忽略额外根目录,保留工作区与私有临时目录权限。ACL 包、runner 参数与工作区 SID 保持不变。POSIX 进程后端继续执行额外根目录授权。
|
||||
|
||||
Windows 进程的额外根目录支持推迟到更好的沙箱后端能够实现该功能、无需扩展 ACL 定制时。模型上下文明示文件工具授权与 Windows 进程权限的区别。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**为每个目录或目录集合分配 SID。** 这些设计可以逐次执行选择进程权限,但需要更多 ACL 修改、身份管理与生命周期处理。该能力不足以证明扩展此机制的必要性。
|
||||
|
||||
**存在额外授权时拒绝进程。** 当记忆插件注册目录时,这会阻止无关的 PowerShell 和工作区操作运行,即使命令从不访问该目录。
|
||||
|
||||
## 影响
|
||||
|
||||
Windows 记忆插件可以通过 `write` 与 `edit` 访问注册的目录,同时普通工作区命令继续运行。shell 命令或 PTC 程序无法使用该额外授权。这明确接受文件工具与进程工具的能力差异;注册和清理仍限定于会话,已有进程保留启动时的权限。
|
||||
+10
-10
@@ -9,14 +9,14 @@
|
||||
en: f62d87b7623a9904
|
||||
zh: 48bbb7c35298f96c
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
|
||||
en: 90fd9a7cf5e25d03
|
||||
zh: 916b1eac07da4eb6
|
||||
en: 10e9cfb7972cff03
|
||||
zh: 8406ba64d72a1fcc
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
|
||||
en: a5bcdf2a6133abfe
|
||||
zh: 477d4915d61945e3
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
|
||||
en: 3bc4bd8f6d4588c8
|
||||
zh: e4760533aa152a5d
|
||||
en: 1ba89a61a733b371
|
||||
zh: 4fa134ad1b2059a1
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered:
|
||||
en: 6119e2aff3b6be5a
|
||||
zh: 4237c596c10e1ee9
|
||||
@@ -30,14 +30,14 @@
|
||||
en: e2c573d90c4ddeb9
|
||||
zh: f0259213e1321722
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences:
|
||||
en: 883c393d20d42da3
|
||||
zh: 541fdf737114b7ae
|
||||
en: dd43744417f207a5
|
||||
zh: d7b4fee1e2d87b60
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing:
|
||||
en: 2175317fcb25a52d
|
||||
zh: 076cee14f261f85e
|
||||
en: b223cb2cf13193c9
|
||||
zh: 61ddd0a0b99f8f56
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related:
|
||||
en: da294868c980f138
|
||||
zh: 8700a7146483b0b8
|
||||
en: dd16f0c5815df991
|
||||
zh: df3c1cf511ee52bf
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped:
|
||||
en: 5262178f0035b215
|
||||
zh: 10505c394ea33ee3
|
||||
+4
-6
@@ -10,7 +10,7 @@ The original [historical sandbox decision](../../archived/feature/2026-07-06-san
|
||||
|
||||
## Decision
|
||||
|
||||
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct directory and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire a shared directory capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A writable root equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, ...directory SIDs, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing directory ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (otherwise the first directory SID, or Everyone when neither is present) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/Line truncated
|
||||
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.Line truncated
|
||||
|
||||
## ACL diagnosis and recovery
|
||||
|
||||
@@ -22,7 +22,7 @@ Both extracted and recovery scripts are user-writable, so this workflow never re
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the permitted-directory and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
|
||||
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -40,16 +40,14 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the standing capabilities for their permitted directories but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing directory security-descriptor mutations (the reuse cache, plus old ACEs when a directory is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree directory propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
|
||||
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
|
||||
|
||||
## Testing
|
||||
|
||||
The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, per-directory and temp argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins directory/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, directory/temp SID validation, sibling temp denial under shared directory SIDs, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence.
|
||||
The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence.
|
||||
|
||||
## Related
|
||||
|
||||
The [per-directory identity decision](../architecture/2026-10-28-windows-directory-write-identities.md) replaces the single workspace capability with independently reusable directory capabilities; this note retains the restricted-token mechanism and its limitations.
|
||||
|
||||
The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes.
|
||||
|
||||
The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here.
|
||||
|
||||
+4
-6
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的目录能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的目录能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果可写根目录等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、全部目录 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻目录 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(否则继承第一个目录 SID,两者均不存在时继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
|
||||
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
|
||||
|
||||
<a id="acl-diagnosis-and-recovery"></a>
|
||||
## ACL 诊断与恢复
|
||||
@@ -23,7 +23,7 @@ Status: implemented
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有获准目录与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
|
||||
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -41,16 +41,14 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
|
||||
|
||||
## 后果
|
||||
|
||||
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享其获准目录的常驻能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);目录常驻安全描述符改动(复用缓存,以及目录改名后的旧 ACE)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;目录授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
|
||||
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
|
||||
|
||||
## 测试
|
||||
|
||||
产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、逐目录与临时目录 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住目录/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、目录/临时目录 SID 验证、共享目录 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。
|
||||
产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。
|
||||
|
||||
## Related
|
||||
|
||||
[逐目录身份决策](../architecture/2026-10-28-windows-directory-write-identities.zh.md)将单一工作区能力替换为可独立复用的目录能力;本笔记保留受限令牌机制及其限制。
|
||||
|
||||
[pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。
|
||||
|
||||
[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。
|
||||
|
||||
@@ -2926,7 +2926,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-sandbox-local`
|
||||
|
||||
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -2986,7 +2986,7 @@ export interface Config {
|
||||
* automatic repository or gitfile discovery. Entries are execution-world
|
||||
* paths: this service validates absoluteness and detaches the list, while
|
||||
* the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
|
||||
* `read-only`.
|
||||
* `read-only`. Windows process confinement ignores them; harness file tools honor them.
|
||||
*/
|
||||
additionalWritableRoots?: string[]
|
||||
}
|
||||
|
||||
@@ -2928,7 +2928,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-sandbox-local`
|
||||
|
||||
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -2988,7 +2988,7 @@ export interface Config {
|
||||
* automatic repository or gitfile discovery. Entries are execution-world
|
||||
* paths: this service validates absoluteness and detaches the list, while
|
||||
* the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
|
||||
* `read-only`.
|
||||
* `read-only`. Windows process confinement ignores them; harness file tools honor them.
|
||||
*/
|
||||
additionalWritableRoots?: string[]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: e0d4876306b192c3
|
||||
zh: 63baaa0c79e87deb
|
||||
/process-sandbox/modes-and-enforcement:
|
||||
en: 6777e1b1270ec3e1
|
||||
zh: 22533fb3304c1dd8
|
||||
en: 3e518b62c9bbcd6e
|
||||
zh: d59d4a3830ce7cf6
|
||||
/process-sandbox/per-call-policy:
|
||||
en: 53850780ce70e3d8
|
||||
zh: 60769c319d778e0e
|
||||
|
||||
@@ -8,13 +8,13 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox
|
||||
|
||||
## Modes and enforcement
|
||||
|
||||
`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, explicit extra directories, and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
|
||||
`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root, extra directories where supported, and the backend's promised temp area; `danger-full-access` bypasses confinement. Windows subprocesses ignore extra directory grants; the harness file tools enforce them independently. Network and process visibility are outside this vocabulary.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* File-effect policy for confined processes. `read-only` permits only required
|
||||
* sinks such as `/dev/null`; `workspace-write` also permits the workspace,
|
||||
* explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* and process visibility are outside this vocabulary.
|
||||
*/
|
||||
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
@@ -57,6 +57,7 @@ interface SandboxExecutionPolicy {
|
||||
* Extra physical absolute directories `workspace-write` may also write under.
|
||||
* Absence means none. The enforcing host rejects missing, non-directory, and
|
||||
* symlinked entries on each call. Remote paths are checked on the remote host.
|
||||
* Windows process confinement ignores these; harness filesystem operations honor them.
|
||||
* These grant nothing under `read-only` or `danger-full-access`.
|
||||
*/
|
||||
additionalWritableRoots?: readonly string[]
|
||||
@@ -64,7 +65,7 @@ interface SandboxExecutionPolicy {
|
||||
* Opaque identity of the calling session (the branded `dsh-session`
|
||||
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
|
||||
* each live session/workspace pair a random private temp directory and SID,
|
||||
* while standing grants are keyed by individual writable directories); absent
|
||||
* while the workspace SID and standing grant remain per-workspace); absent
|
||||
* for agentless calls, which fall back to per-call backend state.
|
||||
*/
|
||||
sessionId?: SessionId
|
||||
@@ -221,6 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
|
||||
* registration. Other grants to the directory remain effective. Existing
|
||||
* operations and persistent processes retain their resolved permissions.
|
||||
* Grants are runtime-only and must be registered again after resume or fork.
|
||||
* On Windows, only harness file tools honor extra grants; process permissions stay unchanged.
|
||||
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
|
||||
* @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
|
||||
* @returns the exact Cordis effect disposer for this registration.
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 模式与强制执行
|
||||
|
||||
`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、显式额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。
|
||||
`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录、受支持的额外目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。Windows 子进程忽略额外目录授权;Harness 文件工具独立执行这些授权。网络与进程可见性不在此处的定义范围内。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* File-effect policy for confined processes. `read-only` permits only required
|
||||
* sinks such as `/dev/null`; `workspace-write` also permits the workspace,
|
||||
* explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* and process visibility are outside this vocabulary.
|
||||
*/
|
||||
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
@@ -57,6 +57,7 @@ interface SandboxExecutionPolicy {
|
||||
* Extra physical absolute directories `workspace-write` may also write under.
|
||||
* Absence means none. The enforcing host rejects missing, non-directory, and
|
||||
* symlinked entries on each call. Remote paths are checked on the remote host.
|
||||
* Windows process confinement ignores these; harness filesystem operations honor them.
|
||||
* These grant nothing under `read-only` or `danger-full-access`.
|
||||
*/
|
||||
additionalWritableRoots?: readonly string[]
|
||||
@@ -64,7 +65,7 @@ interface SandboxExecutionPolicy {
|
||||
* Opaque identity of the calling session (the branded `dsh-session`
|
||||
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
|
||||
* each live session/workspace pair a random private temp directory and SID,
|
||||
* while standing grants are keyed by individual writable directories); absent
|
||||
* while the workspace SID and standing grant remain per-workspace); absent
|
||||
* for agentless calls, which fall back to per-call backend state.
|
||||
*/
|
||||
sessionId?: SessionId
|
||||
@@ -221,6 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
|
||||
* registration. Other grants to the directory remain effective. Existing
|
||||
* operations and persistent processes retain their resolved permissions.
|
||||
* Grants are runtime-only and must be registered again after resume or fork.
|
||||
* On Windows, only harness file tools honor extra grants; process permissions stay unchanged.
|
||||
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
|
||||
* @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
|
||||
* @returns the exact Cordis effect disposer for this registration.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/sandbox-unsupported-policy-error/guide.md
|
||||
/:
|
||||
en: d7c1fc703991b92a
|
||||
zh: f5cddebaa6b311a0
|
||||
/remove-sandboxunsupportedpolicyerror-imports:
|
||||
en: fc8fa3704fa036ce
|
||||
zh: e96b09c9882f760b
|
||||
/remove-sandboxunsupportedpolicyerror-imports/change:
|
||||
en: 3ca1bb8d19b7781e
|
||||
zh: 9f3800c847791344
|
||||
/remove-sandboxunsupportedpolicyerror-imports/migration:
|
||||
en: 29b8d3231a83e606
|
||||
zh: 24416c92a4dc9b71
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The sandbox package removes SandboxUnsupportedPolicyError; Windows processes ignore additional writable roots."
|
||||
---
|
||||
|
||||
# Remove SandboxUnsupportedPolicyError imports
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-sandbox` no longer exports `SandboxUnsupportedPolicyError`. Windows subprocess confinement ignores additional writable directories instead of rejecting execution with `SANDBOX_UNSUPPORTED_POLICY`. The harness `write` and `edit` tools still honor those directories; Windows processes retain workspace and private-temp access under `workspace-write`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Remove imports and error handlers specific to `SandboxUnsupportedPolicyError` or `SANDBOX_UNSUPPORTED_POLICY`.
|
||||
2. Use the harness file tools for writes to additional directories on Windows. Do not assume a successful process launch grants those directories.
|
||||
3. Rebuild the consumer and verify that workspace commands run while extra directories remain accessible only through the file tools.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "sandbox 包移除 SandboxUnsupportedPolicyError;Windows 进程忽略额外可写根目录。"
|
||||
---
|
||||
|
||||
# 移除 SandboxUnsupportedPolicyError 导入
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-sandbox` 不再导出 `SandboxUnsupportedPolicyError`。Windows 子进程隔离忽略额外可写目录,不再以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝执行。Harness 的 `write` 与 `edit` 工具仍执行这些目录的授权;Windows 进程在 `workspace-write` 下保留工作区与私有临时目录访问权限。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 移除针对 `SandboxUnsupportedPolicyError` 或 `SANDBOX_UNSUPPORTED_POLICY` 的导入与错误处理。
|
||||
2. 在 Windows 上使用 Harness 文件工具写入额外目录。不要假定进程启动成功就意味着这些目录已获授权。
|
||||
3. 重新构建消费方,验证工作区命令可以运行,额外目录仍仅能通过文件工具访问。
|
||||
@@ -1,16 +0,0 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
|
||||
/:
|
||||
en: dbd8a58fb86d9bd4
|
||||
zh: 1e8f6661b7c9122d
|
||||
/windows-acl-directory-identities:
|
||||
en: fd8ac37f696dac64
|
||||
zh: b5531e3c276b0c88
|
||||
/windows-acl-directory-identities/change:
|
||||
en: 2893ccdcc064e754
|
||||
zh: 5ced21fcad53bbaf
|
||||
/windows-acl-directory-identities/migration:
|
||||
en: f3512bba3f54f294
|
||||
zh: 98f2bc1de08c0c8b
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Windows ACL callers use derived per-directory SIDs instead of supplying one shared write SID."
|
||||
---
|
||||
|
||||
# Windows ACL directory identities
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. `writableDirs` is now readonly and contains canonical, deduplicated paths. Windows accepts valid additional writable directories, and `@deepseek-ai/dsh-sandbox` removes `SandboxUnsupportedPolicyError` and its `SANDBOX_UNSUPPORTED_POLICY` code. Users of the standard `dsh` profiles need no configuration changes.
|
||||
|
||||
## Migration
|
||||
|
||||
1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. Do not mutate `writableDirs`; construct a new instance for a different directory list. Compare its entries with canonical paths rather than the original input spellings.
|
||||
2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories.
|
||||
3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag.
|
||||
4. Remove imports and catches for `SandboxUnsupportedPolicyError` and checks for `SANDBOX_UNSUPPORTED_POLICY`; Windows supports valid extra directories instead of rejecting that policy.
|
||||
5. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供一个共享的写入 SID。"
|
||||
---
|
||||
|
||||
# Windows ACL 目录身份
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。`writableDirs` 现为只读,并包含规范化、去重后的路径。Windows 接受有效的额外可写目录,`@deepseek-ai/dsh-sandbox` 移除 `SandboxUnsupportedPolicyError` 及其 `SANDBOX_UNSUPPORTED_POLICY` 错误码。标准 `dsh` profile 的用户无需修改配置。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。不要修改 `writableDirs`;目录列表变化时创建新实例。将其条目与规范路径比较,而非原始输入拼写。
|
||||
2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。
|
||||
3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。
|
||||
4. 移除 `SandboxUnsupportedPolicyError` 的导入与捕获,以及对 `SANDBOX_UNSUPPORTED_POLICY` 的检查;Windows 支持有效的额外目录,不再拒绝该策略。
|
||||
5. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。
|
||||
@@ -2585,7 +2585,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'registerWritableRoot(session: Session, directory: string): () => void',
|
||||
description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.',
|
||||
description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork. On Windows, only harness file tools honor extra grants; process permissions stay unchanged.',
|
||||
parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.' }],
|
||||
returns: 'the exact Cordis effect disposer for this registration.',
|
||||
throws: ['when the directory is relative or contains a NUL byte.'],
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('read-only', () => {
|
||||
describe('workspace-write containment', () => {
|
||||
beforeEach(() => boot('workspace-write'))
|
||||
|
||||
it('applies session grants to later writes and removes them when their plugin unloads', async ({ onTestFinished }) => {
|
||||
it('applies session grants to writes and edits until their plugin unloads', async ({ onTestFinished }) => {
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const first = Session.create(SessionId('memory-first'))
|
||||
const second = Session.create(SessionId('memory-second'))
|
||||
@@ -100,6 +100,10 @@ describe('workspace-write containment', () => {
|
||||
const write = async (session: Session, content: string) => fs.writeText(
|
||||
await target(path), content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }),
|
||||
)
|
||||
const edit = async (session: Session) => fs.editText(
|
||||
await target(path), { oldString: 'memory', newString: 'edited', replaceAll: false },
|
||||
undefined, undefined, ctx.sandboxPolicy.resolve({ session }),
|
||||
)
|
||||
await expect(write(first, 'denied')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
const plugin = await ctx.plugin({
|
||||
inject: ['sandboxPolicy'],
|
||||
@@ -108,12 +112,17 @@ describe('workspace-write containment', () => {
|
||||
await write(first, 'memory')
|
||||
expect(await readFile(path, 'utf8')).toBe('memory')
|
||||
await expect(write(second, 'other session')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
await expect(edit(second)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
await edit(first)
|
||||
expect(await readFile(path, 'utf8')).toBe('edited')
|
||||
setSandboxMode(first, 'read-only')
|
||||
await expect(write(first, 'read-only')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
await expect(edit(first)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
setSandboxMode(first, 'workspace-write')
|
||||
await write(first, 'updated')
|
||||
await plugin.dispose()
|
||||
await expect(write(first, 'revoked')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
await expect(edit(first)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('updated')
|
||||
})
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 7282c9ec77fd6f01
|
||||
zh: 10e385a9489105e3
|
||||
/deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery:
|
||||
en: f7033dc3f9c073de
|
||||
zh: e667973ac2e40ad0
|
||||
en: b1f9aeca3baf9561
|
||||
zh: a5681f4f49259468
|
||||
/deepseek-ai-dsh-sandbox-local/understand-the-implementation:
|
||||
en: 15db6ec303151ccc
|
||||
zh: 91f62c1f64192f92
|
||||
@@ -36,8 +36,8 @@
|
||||
en: f0c44248c2ca6acc
|
||||
zh: 554e9f706671d222
|
||||
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles:
|
||||
en: c167bc9f232d1103
|
||||
zh: a2172af886eff752
|
||||
en: 008967ee30fbbe46
|
||||
zh: 9e5c6a3e97851a69
|
||||
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects:
|
||||
en: 62adfc9229c656cc
|
||||
zh: 11b1534b6d61e5de
|
||||
@@ -54,8 +54,8 @@
|
||||
en: e290ccc9b3c92c08
|
||||
zh: 469589ac12cdadab
|
||||
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work:
|
||||
en: 71f863f5ee97d9ba
|
||||
zh: cabc01953e6cb94d
|
||||
en: 08b89c59db310961
|
||||
zh: b505db4148754f94
|
||||
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note:
|
||||
en: cc273320f6a1beaf
|
||||
zh: 5c57a312ddc50651
|
||||
|
||||
@@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E
|
||||
|
||||
An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
|
||||
|
||||
Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts.
|
||||
Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only.
|
||||
|
||||
-----
|
||||
|
||||
@@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`).
|
||||
|
||||
The Windows rung derives one deterministic write SID per canonical writable directory and reuses its standing ACE across permission combinations, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
|
||||
The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
|
||||
|
||||
With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources.
|
||||
|
||||
@@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
|
||||
|
||||
- **Windows temp separation** — writable roots cannot contain the ambient temp root or overlap the session's private temp directory. Those overlaps would expose private temp files through a shared directory capability.
|
||||
- **Windows extra roots** — Additional directory grants apply to the harness `write` and `edit` tools only. PowerShell, terminals, PTC, and other sandboxed processes retain workspace and private-temp access under `workspace-write`. Extra grants do not change their ACLs or block their execution; see the [decision](../../../.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.md).
|
||||
- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
|
||||
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
|
||||
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
|
||||
|
||||
@@ -56,7 +56,7 @@ kind: "package-reference"
|
||||
|
||||
不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
|
||||
|
||||
Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。
|
||||
Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。
|
||||
|
||||
-----
|
||||
|
||||
@@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名
|
||||
|
||||
Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。
|
||||
|
||||
Windows 档为每个规范可写目录派生一个确定性写入 SID,并在不同权限组合之间复用其常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
|
||||
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
|
||||
|
||||
当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。
|
||||
|
||||
@@ -130,7 +130,7 @@ Windows 档为每个规范可写目录派生一个确定性写入 SID,并在
|
||||
|
||||
这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。
|
||||
|
||||
- **Windows 临时目录隔离**——可写根目录不能包含系统临时根目录,也不能与会话的私有临时目录重叠。这类重叠会通过共享目录能力暴露私有临时文件。
|
||||
- **Windows 额外根目录**——额外目录授权仅适用于 Harness 的 `write` 与 `edit` 工具。PowerShell、终端、PTC 及其他受沙箱限制的进程在 `workspace-write` 下保留工作区与私有临时目录访问权限。额外授权不修改其 ACL,也不阻塞其执行;参见[决策](../../../.agents/notes/implemented/architecture/2026-10-29-windows-file-tool-directory-grants.zh.md)。
|
||||
- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
|
||||
- **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
|
||||
- **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
* classification facts. Missing or unusable confinement fails closed rather
|
||||
* than returning the original argv.
|
||||
*
|
||||
* The windows-acl rung owns standing grants keyed by canonical directory.
|
||||
* Each directory has a deterministic write SID, and an execution carries only
|
||||
* the directory SIDs its current policy permits. Every live session/workspace
|
||||
* pair has a separate random private temp directory and revocable temp SID.
|
||||
* The runner receives both identities and does not manage their DACLs.
|
||||
* Enforcement remains partial because hard links alias file objects, reads
|
||||
* stay unconfined, and AppContainer ACLs can deny reads to the child.
|
||||
* The windows-acl rung additionally owns the write grants: the write SID is
|
||||
* the per-WORKSPACE identity derived from the canonical workspace path
|
||||
* (`workspaceWriteSid`), while every live session receives a RANDOM private
|
||||
* temp directory and its own derived capability (`tempWriteSid`). The
|
||||
* workspace-root ACE materializes once per workspace per server lifetime
|
||||
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
|
||||
* every later provision O(1) instead of re-propagating the tree per
|
||||
* session); the private-temp ACEs are revoked on dispose. The runner
|
||||
* receives both SIDs (their presence marks the seam-managed contract) and
|
||||
* stops managing DACLs itself. The rung reports partial enforcement because
|
||||
* NTFS hard links alias one file object across paths, reads stay unconfined,
|
||||
* and a tree another AppContainer tool has ACL'd with a package SID is not
|
||||
* readable by the Low-integrity child.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
@@ -28,10 +34,10 @@ import {
|
||||
} from '@deepseek-ai/node-addon-system/landlock-run'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
@@ -238,7 +244,7 @@ const RUNNER_FAILURE_RULES = {
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
|
||||
* chain verdict and, on the windows-acl rung, the write grants
|
||||
* ({@link AclWriteGrant}: standing grants per writable directory
|
||||
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
|
||||
* and the revocable private-temp grant per live session/workspace pair, the
|
||||
* latter revoked on provider dispose); the one-time probes spawn nothing
|
||||
* else.
|
||||
@@ -259,9 +265,14 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
private readonly probeTimeoutMs: number
|
||||
/** Cached chain verdict; undefined until the first confined wrap needs it. */
|
||||
private selectedRunner: SelectedRunner | 'unavailable' | undefined
|
||||
/** Standing grants keyed by canonical directory; ACEs outlive the provider. */
|
||||
private readonly directoryGrants = new Map<string, AclWriteGrant>()
|
||||
/** Revocable private temp grants per live session/workspace pair. */
|
||||
/**
|
||||
* Server-lifetime write grants (windows-acl rung): the STANDING
|
||||
* workspace-root grant per workspace (its ACE is the cross-session reuse
|
||||
* cache and outlives the provider — never revoked) and the REVOCABLE
|
||||
* private-temp grant per live session/workspace pair (revoked on provider
|
||||
* dispose).
|
||||
*/
|
||||
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly tempCapabilities = new Map<string, AclTempCapability>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -344,15 +355,20 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Windows runner invocation for the current writable-directory set.
|
||||
* Session calls reuse standing directory grants and a private temp capability.
|
||||
* Agentless calls let the runner materialize the set and own its private temp.
|
||||
* The windows-acl runner argv for one policy. With a calling session (the
|
||||
* policy's `sessionId`) under workspace-write, the grants are materialized
|
||||
* once per provider lifetime — the standing workspace-root grant per
|
||||
* workspace and a revocable, RANDOM private-temp capability per live
|
||||
* session/workspace pair. The runner receives `--write-sid` plus
|
||||
* `--temp-write-sid` and grants nothing itself. Agentless workspace-write
|
||||
* calls pass the ambient temp ROOT and no SID flags: the runner creates and
|
||||
* removes a random private child directory for that one invocation.
|
||||
* Additional directory grants apply only to harness filesystem operations
|
||||
* on Windows; process confinement keeps the workspace and private temp grants.
|
||||
* @param policy - the resolved per-call policy.
|
||||
* @returns the runner invocation.
|
||||
*/
|
||||
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
|
||||
const roots = workspaceWritableRoots(policy)
|
||||
const extraArgs = roots.slice(1).flatMap(root => ['--writable-root', root])
|
||||
const sessionId = policy.sessionId
|
||||
if (sessionId === undefined || policy.mode === 'read-only') {
|
||||
return [
|
||||
@@ -360,54 +376,54 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
'--workspace', policy.workspaceRoot,
|
||||
'--temp', tmpdir(),
|
||||
'--mode', policy.mode,
|
||||
...extraArgs,
|
||||
]
|
||||
}
|
||||
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot, roots)
|
||||
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
'--temp', temp.dir,
|
||||
'--mode', policy.mode,
|
||||
...extraArgs,
|
||||
...roots.flatMap(root => ['--write-sid', directoryWriteSid(root)]),
|
||||
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
|
||||
'--temp-write-sid', temp.writeSid,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or materialize each permitted directory's standing ACE.
|
||||
* Private temp capabilities remain scoped to the session/workspace pair;
|
||||
* changing directory sets does not revoke grants held by running processes.
|
||||
* Failed temp materialization revokes its grant and removes its directory.
|
||||
* Materialize one workspace-write policy's ACEs once per provider
|
||||
* lifetime. The workspace SID and standing root grant are shared by the
|
||||
* workspace. The temp directory is random and carries a distinct SID, so
|
||||
* another session on the same workspace cannot use the shared workspace
|
||||
* SID to enter it. A fresh provider always chooses a new path; crash
|
||||
* residue therefore cannot collide with or authorize a resumed session.
|
||||
* Fail-closed: a half-materialized temp grant is revoked and its directory
|
||||
* removed before the error propagates.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @param roots - the complete canonical writable-directory set for this execution.
|
||||
* @returns the pair's private temp directory and write capability.
|
||||
*/
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, roots: readonly string[]): AclTempCapability {
|
||||
for (const root of roots) assertTempRootOutsideWorkspace(root, tmpdir())
|
||||
const key = JSON.stringify([String(sessionId), workspaceRoot])
|
||||
const existing = this.tempCapabilities.get(key)
|
||||
if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir)
|
||||
for (const root of roots) {
|
||||
const cachedGrant = this.directoryGrants.get(root)
|
||||
const directoryGrant = cachedGrant ?? AclWriteGrant.create(directoryWriteSid(root))
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
|
||||
assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
|
||||
const writeSid = workspaceWriteSid(workspaceRoot)
|
||||
if (!this.workspaceGrants.has(workspaceRoot)) {
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
try {
|
||||
directoryGrant.add(root, true)
|
||||
grant.add(workspaceRoot, true)
|
||||
} catch (error) {
|
||||
if (cachedGrant !== undefined) throw error
|
||||
// Standing ACEs remain reusable; only the failed entry's allocations
|
||||
// are released. A later execution retries this directory's grant.
|
||||
// Free the SID; a standing ACE (if the apply succeeded before a
|
||||
// post-apply throw) is the intended end state, not an error
|
||||
// artifact — nothing to revoke.
|
||||
try {
|
||||
directoryGrant.dispose()
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed')
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.directoryGrants.set(root, directoryGrant)
|
||||
this.workspaceGrants.set(workspaceRoot, grant)
|
||||
}
|
||||
const key = JSON.stringify([String(sessionId), workspaceRoot])
|
||||
const existing = this.tempCapabilities.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
const tempSid = tempWriteSid(tempDir)
|
||||
@@ -449,9 +465,9 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* OS temp hygiene (or manual removal) eventually reclaims it.
|
||||
*/
|
||||
private revokeAclGrants(): void {
|
||||
if (this.directoryGrants.size === 0 && this.tempCapabilities.size === 0) return
|
||||
if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
|
||||
const failures: unknown[] = []
|
||||
for (const grant of [...this.directoryGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
|
||||
for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (error) {
|
||||
@@ -465,7 +481,7 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.directoryGrants.clear()
|
||||
this.workspaceGrants.clear()
|
||||
this.tempCapabilities.clear()
|
||||
if (failures.length > 0) {
|
||||
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* windows-acl grant ownership through the real LocalSandboxProvider: one
|
||||
* standing capability per directory plus one random, distinct, revocable
|
||||
* standing capability per workspace plus one random, distinct, revocable
|
||||
* temp capability per live session/workspace pair. The Win32 grant surface
|
||||
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,21 +13,18 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
||||
const mockState = vi.hoisted(() => ({
|
||||
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
|
||||
addFailure: undefined as Error | undefined,
|
||||
addFailurePath: undefined as string | undefined,
|
||||
/** Restrict an add failure to standing (workspace) or revocable (temp). */
|
||||
addFailureStanding: undefined as boolean | undefined,
|
||||
createTempFailure: undefined as Error | undefined,
|
||||
disposeFailure: undefined as Error | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-sandbox-windows-acl')>()
|
||||
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
class MockAclWriteGrant {
|
||||
readonly writeSid: string
|
||||
readonly added: Array<{ path: string; standing: boolean }> = []
|
||||
@@ -43,7 +40,6 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
|
||||
add(path: string, standing = false): void {
|
||||
this.added.push({ path, standing })
|
||||
if (mockState.addFailure !== undefined
|
||||
&& (mockState.addFailurePath === undefined || mockState.addFailurePath === path)
|
||||
&& (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) {
|
||||
throw mockState.addFailure
|
||||
}
|
||||
@@ -54,7 +50,6 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
registerAclDiagnosisSkill: vi.fn(),
|
||||
AclWriteGrant: MockAclWriteGrant,
|
||||
assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => {
|
||||
@@ -64,10 +59,13 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
|
||||
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
|
||||
}
|
||||
},
|
||||
workspaceWriteSid: () => 'S-1-4-42-42',
|
||||
tempWriteSid: (path: string) => `TEMP:${path}`,
|
||||
}
|
||||
})
|
||||
|
||||
const WORKSPACE_SID = 'S-1-4-42-42'
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSandboxProvider, {})
|
||||
@@ -85,17 +83,12 @@ function flag(argv: readonly string[], name: string): string | undefined {
|
||||
return index < 0 ? undefined : argv[index + 1]
|
||||
}
|
||||
|
||||
function flags(argv: readonly string[], name: string): string[] {
|
||||
return argv.filter((_value, index) => index > 0 && argv[index - 1] === name)
|
||||
}
|
||||
|
||||
describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.grants = []
|
||||
mockState.addFailure = undefined
|
||||
mockState.addFailurePath = undefined
|
||||
mockState.addFailureStanding = undefined
|
||||
mockState.createTempFailure = undefined
|
||||
mockState.disposeFailure = undefined
|
||||
@@ -136,25 +129,30 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
expect(tempDir).toBeDefined()
|
||||
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
|
||||
expect(tempSid).toBe(`TEMP:${tempDir}`)
|
||||
expect(tempSid).not.toBe(directoryWriteSid(ws))
|
||||
expect(tempSid).not.toBe(WORKSPACE_SID)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', directoryWriteSid(ws),
|
||||
'--write-sid', WORKSPACE_SID,
|
||||
'--temp-write-sid', tempSid,
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toEqual([
|
||||
expect.objectContaining({ writeSid: directoryWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }),
|
||||
expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }),
|
||||
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
|
||||
])
|
||||
expect(existsSync(tempDir ?? '')).toBe(true)
|
||||
|
||||
expect((await sandbox.confine(['pwsh', '/Command', 'x'], policy)).argv).toEqual(confined.argv)
|
||||
const extra = workspaceRoot()
|
||||
scratch.push(extra)
|
||||
expect((await sandbox.confine(['pwsh', '/Command', 'x'], {
|
||||
...policy, additionalWritableRoots: [extra],
|
||||
})).argv).toEqual(confined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants.flatMap(grant => grant.added.map(added => added.path))).toEqual([ws, tempDir])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(mockState.grants.every(grant => grant.disposed)).toBe(true)
|
||||
@@ -164,108 +162,6 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses each directory grant across changing sets and workspaces without granting sibling roots', async () => {
|
||||
const { sandbox, fiber } = await setup()
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
const memory = workspaceRoot()
|
||||
const cache = workspaceRoot()
|
||||
const otherWorkspace = workspaceRoot()
|
||||
scratch.push(ws, memory, cache, otherWorkspace)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') }
|
||||
const base = await sandbox.confine(['true'], policy)
|
||||
const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] })
|
||||
expect(flags(expanded.argv, '--write-sid')).toEqual([ws, memory, cache].map(directoryWriteSid))
|
||||
expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp'))
|
||||
expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid'))
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
const memoryGrant = mockState.grants.find(grant => grant.writeSid === directoryWriteSid(memory))!
|
||||
const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] })
|
||||
expect(flags(reordered.argv, '--write-sid')).toEqual([ws, cache, memory].map(directoryWriteSid))
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv)
|
||||
expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
|
||||
|
||||
const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') })
|
||||
expect(flags(sibling.argv, '--write-sid')).toEqual([directoryWriteSid(ws)])
|
||||
expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid'))
|
||||
const other = await sandbox.confine(['true'], {
|
||||
...policy, sessionId: SessionId('other'), workspaceRoot: otherWorkspace, additionalWritableRoots: [memory],
|
||||
})
|
||||
expect(flags(other.argv, '--write-sid')).toEqual([otherWorkspace, memory].map(directoryWriteSid))
|
||||
expect(mockState.grants).toHaveLength(7)
|
||||
expect(mockState.grants.filter(grant => grant.writeSid === directoryWriteSid(memory))).toEqual([memoryGrant])
|
||||
for (const root of [ws, memory, cache, otherWorkspace]) {
|
||||
const grant = mockState.grants.find(candidate => candidate.writeSid === directoryWriteSid(root))!
|
||||
expect(grant.added.every(added => added.path === root && added.standing)).toBe(true)
|
||||
}
|
||||
} finally { await fiber.dispose(); cleanup() }
|
||||
})
|
||||
|
||||
it('reapplies a cached directory grant when an extra directory is recreated', async () => {
|
||||
const { sandbox, fiber } = await setup()
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
const extra = workspaceRoot()
|
||||
scratch.push(ws, extra)
|
||||
const policy: SandboxPolicy = {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra],
|
||||
}
|
||||
const first = await sandbox.confine(['true'], policy)
|
||||
const directoryGrant = mockState.grants[1]!
|
||||
rmSync(extra, { recursive: true })
|
||||
mkdirSync(extra)
|
||||
const second = await sandbox.confine(['true'], policy)
|
||||
expect(second.argv).toEqual(first.argv)
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[1]).toBe(directoryGrant)
|
||||
expect(directoryGrant.added).toEqual([extra, extra].map(path => ({ path, standing: true })))
|
||||
} finally { await fiber.dispose(); cleanup() }
|
||||
})
|
||||
|
||||
it('retains successful directory grants and retries the directory that failed', async () => {
|
||||
const { sandbox, fiber } = await setup()
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
const extra = workspaceRoot()
|
||||
scratch.push(ws, extra)
|
||||
const policy: SandboxPolicy = {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('set-retry'), additionalWritableRoots: [extra],
|
||||
}
|
||||
mockState.addFailure = new Error('extra grant failed')
|
||||
mockState.addFailurePath = extra
|
||||
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed')
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({ added: [{ path: ws, standing: true }], disposed: false })
|
||||
expect(mockState.grants[1]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: true })
|
||||
mockState.addFailure = undefined
|
||||
await sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
expect(mockState.grants[2]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: false })
|
||||
mockState.addFailure = new Error('cached grant refresh failed')
|
||||
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed')
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
expect(mockState.grants[2]!.disposed).toBe(false)
|
||||
mockState.addFailure = undefined
|
||||
await sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
} finally { await fiber.dispose(); cleanup() }
|
||||
})
|
||||
|
||||
it('refuses a changed set overlapping its private temp before adding standing grants', async () => {
|
||||
const { sandbox, fiber } = await setup()
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-overlap') }
|
||||
const confined = await sandbox.confine(['true'], policy)
|
||||
const temp = realpathSync.native(flag(confined.argv, '--temp')!)
|
||||
await expect(sandbox.confine(['true'], { ...policy, additionalWritableRoots: [temp] }))
|
||||
.rejects.toThrow('private temp directory must be disjoint from writable directories')
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
} finally { await fiber.dispose(); cleanup() }
|
||||
})
|
||||
|
||||
it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
@@ -285,7 +181,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
|
||||
const upgraded = await sandbox.confine(['true'], workspaceWrite)
|
||||
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(directoryWriteSid(ws))
|
||||
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
await sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
@@ -361,7 +257,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
mockState.disposeFailure = new Error('workspace cleanup exploded')
|
||||
await expect(sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'),
|
||||
})).rejects.toThrow(/directory grant failed and its cleanup also failed/u)
|
||||
})).rejects.toThrow(/workspace grant failed and its cleanup also failed/u)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
} finally {
|
||||
cleanup()
|
||||
|
||||
@@ -430,7 +430,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
})
|
||||
|
||||
describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
it('passes existing extra directories to the Windows runner and skips non-directories', async () => {
|
||||
it.each([RO, WW])('ignores extra directory grants in $mode runner arguments', async (policy) => {
|
||||
const { ctx, sandbox } = await setup({}, {
|
||||
chain: ['windows-acl'],
|
||||
probeWindowsAcl: () => true,
|
||||
@@ -441,11 +441,10 @@ describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
const file = join(extra, 'file.txt')
|
||||
writeFileSync(file, 'file')
|
||||
try {
|
||||
await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory')
|
||||
const confined = await sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })
|
||||
expect(confined.argv).toContain('--writable-root')
|
||||
expect(confined.argv[confined.argv.indexOf('--writable-root') + 1]).toBe(extra)
|
||||
await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined()
|
||||
const original = await sandbox.confine(['true'], policy)
|
||||
await expect(sandbox.confine(['true'], {
|
||||
...policy, additionalWritableRoots: [extra, join(extra, 'missing'), file],
|
||||
})).resolves.toEqual(original)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
en: 6704447e52ec295b
|
||||
zh: 9ed812d9e29d5533
|
||||
/deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration:
|
||||
en: c7e924a923bfac72
|
||||
zh: fdaf930a5800ffa6
|
||||
en: 0616478ef5bb0566
|
||||
zh: e3a5802aefcf8424
|
||||
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
|
||||
en: 28587491ad2ac66a
|
||||
zh: c5d4dc441e3f0459
|
||||
en: 841ea47ef1ed7ee6
|
||||
zh: cd573e67a67742e2
|
||||
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
|
||||
en: 957d72691e470e5c
|
||||
zh: c22a3a750af0e4a3
|
||||
|
||||
@@ -46,7 +46,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and
|
||||
|---|---|---|
|
||||
| `mode` | `read-only` | The deployment default mode a session starts from, validated at load |
|
||||
| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd |
|
||||
| `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them |
|
||||
| `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access |
|
||||
|
||||
For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
|
||||
|
||||
@@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply.
|
||||
|
||||
Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only.
|
||||
Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, `write` and `edit` honor extra directories, while sandboxed processes ignore them and keep workspace and private-temp access; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
### Switching a session's mode
|
||||
|
||||
@@ -134,7 +134,7 @@ Current DSH file policy: workspace-write. Any available operation enforced by th
|
||||
##### Workspace-write with extra directories
|
||||
|
||||
```markdown
|
||||
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Additional writable directories: ["/repo/.git"]. Some platform temporary areas may also be writable.
|
||||
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Additional writable directories: ["/repo/.git"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.
|
||||
```
|
||||
|
||||
##### Danger-full-access
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
|---|---|---|
|
||||
| `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 |
|
||||
| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd |
|
||||
| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 |
|
||||
| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 |
|
||||
|
||||
对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
|
||||
|
||||
@@ -56,7 +56,7 @@ kind: "package-reference"
|
||||
|
||||
插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。
|
||||
|
||||
注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。
|
||||
注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,`write` 与 `edit` 执行额外目录授权,受沙箱限制的进程则忽略这些授权,保留工作区与私有临时目录访问权限;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
|
||||
|
||||
### 切换会话模式
|
||||
|
||||
@@ -134,7 +134,7 @@ Current DSH file policy: workspace-write. Any available operation enforced by th
|
||||
##### 带额外目录的工作区写入
|
||||
|
||||
```markdown
|
||||
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Additional writable directories: ["/repo/.git"]. Some platform temporary areas may also be writable.
|
||||
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Additional writable directories: ["/repo/.git"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.
|
||||
```
|
||||
|
||||
##### 完全访问
|
||||
|
||||
@@ -66,7 +66,7 @@ function renderPolicyContext(policy: SandboxExecutionPolicy): string {
|
||||
const additional = policy.additionalWritableRoots
|
||||
const extra = additional === undefined
|
||||
? ''
|
||||
: ` Additional writable directories: ${JSON.stringify(additional)}.`
|
||||
: ` Additional writable directories: ${JSON.stringify(additional)}. On Windows, these additional grants apply to file tools only, not sandboxed processes.`
|
||||
return `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(policy.workspaceRoot)}.${extra} Some platform temporary areas may also be writable.`
|
||||
}
|
||||
case 'danger-full-access':
|
||||
@@ -106,7 +106,7 @@ export interface Config {
|
||||
* automatic repository or gitfile discovery. Entries are execution-world
|
||||
* paths: this service validates absoluteness and detaches the list, while
|
||||
* the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
|
||||
* `read-only`.
|
||||
* `read-only`. Windows process confinement ignores them; harness file tools honor them.
|
||||
*/
|
||||
additionalWritableRoots?: string[]
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export class SandboxPolicyService extends Service {
|
||||
* registration. Other grants to the directory remain effective. Existing
|
||||
* operations and persistent processes retain their resolved permissions.
|
||||
* Grants are runtime-only and must be registered again after resume or fork.
|
||||
* On Windows, only harness file tools honor extra grants; process permissions stay unchanged.
|
||||
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
|
||||
* @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
|
||||
* @returns the exact Cordis effect disposer for this registration.
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('sandbox:policy request context', () => {
|
||||
additionalWritableRoots: ['/projects/shared/.git', '/projects/shared/worktrees'],
|
||||
})
|
||||
expect(await policyContext(ctx, session('sess-extra', '/projects/current'))).toBe(
|
||||
`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify('/projects/current')}. Additional writable directories: ["/projects/shared/.git","/projects/shared/worktrees"]. Some platform temporary areas may also be writable.`,
|
||||
`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify('/projects/current')}. Additional writable directories: ["/projects/shared/.git","/projects/shared/worktrees"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e7cdbd2652fe80ab
|
||||
zh: 04e1d187313a0bd5
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/summary:
|
||||
en: dac35dbc120ce3ec
|
||||
zh: b46a5c0d31af6f1f
|
||||
en: 777921135c23ee55
|
||||
zh: 7c726cc63995d443
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -21,11 +21,11 @@
|
||||
en: 510d0b1ef7db159d
|
||||
zh: dc2cf25b3db14c63
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/direct-api:
|
||||
en: 0208dc12f0be12ea
|
||||
zh: 3ce871ac35bd1828
|
||||
en: 16596607bbccc2a3
|
||||
zh: 30bc370a5281bf0d
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you:
|
||||
en: 0ae33482d28f637b
|
||||
zh: a2b87b18737a6d07
|
||||
en: 289e65bddf20dd79
|
||||
zh: 7ff72ed64c4b4d1c
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
|
||||
en: 7fc0707debb1e67c
|
||||
zh: e3b5529bf82f868e
|
||||
@@ -33,17 +33,17 @@
|
||||
en: c2cde86c85c2c619
|
||||
zh: a29a114e591f9d3f
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism:
|
||||
en: 4ffc57b0582af92c
|
||||
zh: 8c6fe31d52065f2b
|
||||
en: a37ea37f2d527693
|
||||
zh: 749b5a263d0e1534
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists:
|
||||
en: 60094d4cff20b7cc
|
||||
zh: f332d2d2ba2d8170
|
||||
en: 9c8d9af84f9abd99
|
||||
zh: 1c82b355aa5f210f
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner:
|
||||
en: 7daba96f71c4b02b
|
||||
zh: a92e6ae40dc118a7
|
||||
en: 0847de0e53e50061
|
||||
zh: 90fa3dcaf080c3be
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries:
|
||||
en: 0f87fd641ace67fb
|
||||
zh: c9fa22ca5225f264
|
||||
en: ad02570eae90037f
|
||||
zh: fd78c2318de18dda
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/header-verification-and-source-map:
|
||||
en: c19df6b7a756b779
|
||||
zh: b8267b26bd191d97
|
||||
@@ -57,11 +57,11 @@
|
||||
en: 817eb1b7de13872e
|
||||
zh: 06fe175e41e7459b
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work:
|
||||
en: 4cc129728a9005b7
|
||||
zh: 89fac806fb43b41b
|
||||
en: 046831a864786b2a
|
||||
zh: 4a261fbab3396ea4
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note:
|
||||
en: cc273320f6a1beaf
|
||||
zh: 5c57a312ddc50651
|
||||
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note/future-warnings-and-cleanup-surfaces:
|
||||
en: ea48de0150a65991
|
||||
zh: 2b98931436064513
|
||||
en: 8872c7b4c8ce1cc7
|
||||
zh: c7fb4eefa013c7b0
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
On Windows, this package confines child-process writes and deletes to the workspace, explicit extra directories, and a private temporary directory: `workspace-write` grants these directories, `read-only` grants none. Mount `dsh-sandbox-local` for confined bash and PowerShell commands, or use `AclSandbox`; Win32 failures never cause unrestricted spawning. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
|
||||
On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -39,17 +39,18 @@ Choose it for Windows compositions that confine subprocess file effects under `r
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grants. workspace-write derives one SID per writable directory
|
||||
// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes.
|
||||
// match the grant shape. workspace-write requires distinct workspace and
|
||||
// private-temp identities; pass tempDir: null to disable temp writes.
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceRoot],
|
||||
tempDir,
|
||||
writeSid: workspaceWriteSid(workspaceRoot),
|
||||
tempWriteSid: tempWriteSid(tempDir),
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -58,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants
|
||||
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
```
|
||||
|
||||
The writable-directory security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs.
|
||||
The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs.
|
||||
|
||||
### What confinement gives you
|
||||
|
||||
Under `workspace-write`, the child may write into the workspace, its explicit extra roots, and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
|
||||
Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
|
||||
|
||||
Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session.
|
||||
|
||||
@@ -94,11 +95,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
|
||||
|
||||
### Mechanism
|
||||
|
||||
The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry a capability for each writable directory and a separate private-temp capability, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share authority for their permitted directories without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
|
||||
The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
|
||||
|
||||
### Modes and token lists
|
||||
|
||||
`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: standing directory grants from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
|
||||
`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
|
||||
|
||||
NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working.
|
||||
|
||||
@@ -109,26 +110,26 @@ Authenticated Users is absent from both lists — the WMI namespace security che
|
||||
The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's runner-failure rules match that signature.
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--writable-root <dir>]... [--write-sid <S-1-4-…>]... [--temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting a standing directory ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
|
||||
The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant.
|
||||
|
||||
When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
|
||||
|
||||
### Verified boundaries
|
||||
|
||||
- **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed.
|
||||
- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working where the directory DACL grants them DELETE directly.
|
||||
- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly.
|
||||
- **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite.
|
||||
- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed.
|
||||
- **Hard links are file-object aliases, not path aliases** — an inheritable directory grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations.
|
||||
- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations.
|
||||
- **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected.
|
||||
- **Security descriptor edits are standing directory mutations** — directory ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module.
|
||||
- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — each granted directory's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside that directory, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision.
|
||||
- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module.
|
||||
- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision.
|
||||
- **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement.
|
||||
- **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root.
|
||||
- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing directory SIDs cannot write one another's temp directories.
|
||||
- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories.
|
||||
- **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure.
|
||||
|
||||
### Header verification and source map
|
||||
@@ -178,12 +179,12 @@ One extra catalog entry on Windows: the skill's description enters context with
|
||||
|
||||
These limits define when the backend is a poor fit or needs special operational care. They are current package constraints, not a general Windows comparison or a task backlog.
|
||||
|
||||
- **Directory grants remain cached for the provider lifetime** — the provider retains an `AclWriteGrant` and its native SID allocations for every canonical directory it has granted, until provider disposal. New executions select the permitted directory SIDs; changing that selection does not release cached grants or alter running tokens.
|
||||
- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
|
||||
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual removal reclaims it.
|
||||
- **Standing edits also affect extra directories** — the directory capability ACE, Low label, and Everyone `FILE_DELETE_CHILD` deny persist after plugin removal and provider disposal, including on user directories outside the workspace such as `~/.dsh/memory`. Renaming a directory derives a new SID but leaves the old ACE behind; the standing Low label and deny retain the effects described above.
|
||||
- **Standing workspace ACEs are invisible residue** — renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only), and a future cleanup command may reap them.
|
||||
- **NULL-DACL directories are not identity-preserving under grant+revoke** — a directory with a NULL DACL means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an empty (deny-all) DACL rather than the original NULL DACL. Real workspace and temp directories carry real DACLs, so this stays a documented edge.
|
||||
- **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE.
|
||||
- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-directory identity pays it once per directory per machine, and the exact-ACE skip makes every later provision cheap.
|
||||
- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap.
|
||||
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
|
||||
- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access.
|
||||
- **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue.
|
||||
@@ -200,6 +201,6 @@ This Dev Note is working context for maintainers: undecided directions and open
|
||||
|
||||
#### Future: warnings and cleanup surfaces
|
||||
|
||||
The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing directory ACEs from renamed directories is undecided. Both are open directions, not shipped behavior.
|
||||
The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing workspace ACEs from renamed workspaces is undecided. Both are open directions, not shipped behavior.
|
||||
|
||||
</details>
|
||||
@@ -9,7 +9,7 @@ kind: "package-library"
|
||||
|
||||
## 概述
|
||||
|
||||
在 Windows 上,本包将子进程的写入与删除限制在工作区、显式额外目录和私有临时目录内:`workspace-write` 授予这些目录,`read-only` 均不授予。挂载 `dsh-sandbox-local` 可约束 bash 与 PowerShell 命令,也可使用 `AclSandbox`;Win32 失败绝不会导致不受限制的进程启动。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
|
||||
在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -39,17 +39,18 @@ kind: "package-library"
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grants. workspace-write derives one SID per writable directory
|
||||
// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes.
|
||||
// match the grant shape. workspace-write requires distinct workspace and
|
||||
// private-temp identities; pass tempDir: null to disable temp writes.
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceRoot],
|
||||
tempDir,
|
||||
writeSid: workspaceWriteSid(workspaceRoot),
|
||||
tempWriteSid: tempWriteSid(tempDir),
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -58,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants
|
||||
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
```
|
||||
|
||||
可写目录的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。
|
||||
工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。
|
||||
|
||||
### 隔离给你带来什么
|
||||
|
||||
在 `workspace-write` 下,子进程可以写入工作区、显式额外根目录及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
|
||||
在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
|
||||
|
||||
临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。
|
||||
|
||||
@@ -96,11 +97,11 @@ rmSync(tempDir, { recursive: true, force: true })
|
||||
|
||||
### 机制
|
||||
|
||||
调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 为每个可写目录携带一项能力,并另带独立的私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享其获准目录的权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
|
||||
调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
|
||||
|
||||
### 模式与令牌列表
|
||||
|
||||
`workspace-write`(登录 SID、Everyone、目录 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
|
||||
`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
|
||||
|
||||
NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。
|
||||
|
||||
@@ -111,26 +112,26 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全
|
||||
面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 runner 失败规则匹配该签名。
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--writable-root <dir>]... [--write-sid <S-1-4-…>]... [--temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻目录 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。
|
||||
seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。
|
||||
|
||||
启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。
|
||||
|
||||
### 已验证边界
|
||||
|
||||
- **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。
|
||||
- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,前提是该目录 DACL 直接向其授予 DELETE。
|
||||
- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。
|
||||
- **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。
|
||||
- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。
|
||||
- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承目录授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。
|
||||
- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。
|
||||
- **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。
|
||||
- **安全描述符改动是对真实目录的驻留改动。** 目录 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。
|
||||
- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 每个授权目录的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除该目录内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。
|
||||
- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。
|
||||
- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。
|
||||
- **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。
|
||||
- **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。
|
||||
- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享目录 SID 的两个令牌无法写入彼此的临时目录。
|
||||
- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。
|
||||
- **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。
|
||||
|
||||
### 头文件验证与源码索引
|
||||
@@ -180,12 +181,12 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文
|
||||
|
||||
这些限制说明后端何时不合适,或何时需要特别运维。它们是当前包约束,不是通用 Windows 对比或任务积压。
|
||||
|
||||
- **目录授权缓存保留到提供方释放**——提供方为每个曾授权的规范目录保留一个 `AclWriteGrant` 及其原生 SID 分配,直到提供方释放。新执行选择获准目录的 SID;改变该选择不会释放缓存授权或修改运行中的令牌。
|
||||
- **每个工作区一个写入白名单**——写入 SID 是白名单的基本单位,且就是工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。
|
||||
- **清理按设计尽力而为**——`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。
|
||||
- **常驻改动同样影响额外目录**——目录能力 ACE、Low 标签与针对 Everyone 的 `FILE_DELETE_CHILD` 拒绝项会在插件移除和提供方释放后保留,包括工作区之外的用户目录,如 `~/.dsh/memory`。目录改名会派生新的 SID,但留下旧 ACE;常驻 Low 标签与拒绝项继续产生上文描述的效果。
|
||||
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID),未来的清理命令可以回收它们。
|
||||
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形。
|
||||
- **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按目录身份每台机器每个目录只付一次,精确 ACE 跳过让后续每次供给都很便宜。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。
|
||||
- **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
||||
- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。
|
||||
- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。
|
||||
@@ -202,6 +203,6 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文
|
||||
|
||||
#### 未来:警告与清理表面
|
||||
|
||||
对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名目录常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。
|
||||
对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名工作区常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。
|
||||
|
||||
</details>
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Server-side write-grant materialization. The sandbox seam holds one
|
||||
* standing grant per writable directory and one revocable temp grant per
|
||||
* live session/workspace pair. Directory identities survive by deterministic
|
||||
* standing workspace grant per workspace and one revocable temp grant per
|
||||
* live session/workspace pair. Workspace identities survive by deterministic
|
||||
* derivation and their standing ACE; temp identities derive from random
|
||||
* private paths and are deliberately new after a restart.
|
||||
*
|
||||
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
||||
* instance (revoking every path granted so far); `dispose` revokes every
|
||||
* revocable grant, leaves the standing directory edits in place, and reports
|
||||
* revocable grant, leaves the standing workspace edits in place, and reports
|
||||
* every cleanup failure.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/grant
|
||||
*/
|
||||
@@ -58,7 +58,7 @@ export class AclWriteGrant {
|
||||
* and the world SID their ambient-delete deny names, and open the binding
|
||||
* table (lazily, once per server). Fail-closed: any failure throws — nothing
|
||||
* is granted yet.
|
||||
* @param writeSid - the directory (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
|
||||
* @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
|
||||
* @param api - optional already-resolved bindings (tests).
|
||||
* @returns the ready grant (no ACEs yet).
|
||||
*/
|
||||
@@ -91,21 +91,19 @@ export class AclWriteGrant {
|
||||
* Grant the write ACE, the ambient-delete deny, and the Low mandatory label
|
||||
* on one directory (idempotent: an already-standing exact ACE, deny, and
|
||||
* label skip the eager full-tree re-propagation — see {@link grantWrite})
|
||||
* and record the path once for {@link dispose} unless it is standing. Repeated
|
||||
* calls recheck the ACE so a recreated directory receives its grant. The path is
|
||||
* and record the path for {@link dispose} unless it is standing. The path is
|
||||
* recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
|
||||
* SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
|
||||
* ungranted path is a no-op merge. Callers treat a throw as a failed
|
||||
* materialization and dispose the instance to revoke the paths granted so
|
||||
* far.
|
||||
* @param path - the directory whose DACL and label gain the grant.
|
||||
* @param standing - the edits outlive this grant (the directory reuse
|
||||
* @param standing - the edits outlive this grant (the workspace reuse
|
||||
* cache; dispose() skips revoking it). Default false (revoked on
|
||||
* dispose — the temp-directory lifecycle).
|
||||
*/
|
||||
add(path: string, standing = false): void {
|
||||
const paths = standing ? this.standingPaths : this.revocablePaths
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
;(standing ? this.standingPaths : this.revocablePaths).push(path)
|
||||
grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Windows ACL write-restriction sandbox backend for the DeepSeek Harness
|
||||
* sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/
|
||||
* windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED
|
||||
* token whose restricting SIDs include distinct directory and temp write
|
||||
* token whose restricting SIDs include distinct workspace and temp write
|
||||
* SIDs that this sandbox adds to their owning directories' DACLs — the
|
||||
* intersection check then allows writes exactly where a permitted capability has
|
||||
* intersection check then allows writes exactly where either capability has
|
||||
* a Write ACE, and nowhere else those SIDs are concerned (the check ALSO
|
||||
* inherits the ambient write ACEs of the other restricting SIDs — the
|
||||
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
|
||||
@@ -13,12 +13,13 @@
|
||||
* for the complete boundary). The intersection covers only the object's own
|
||||
* access check, so the token is also lowered to Low integrity and every
|
||||
* granted directory carries a Low no-write-up label and the ambient-delete
|
||||
* deny the `acl` module documents. Each canonical writable directory has a
|
||||
* stable SID ({@link directoryWriteSid}) granted only to that directory.
|
||||
* An execution carries the SIDs of its allowed directories; standing ACEs
|
||||
* are reusable across sessions and permission combinations. Each private
|
||||
* temp directory instead receives its own SID, so sibling sessions cannot
|
||||
* use a shared directory capability to enter one another's temp
|
||||
* deny the `acl` module documents. The write SID is the per-WORKSPACE identity
|
||||
* ({@link workspaceWriteSid}): deterministic from the canonical workspace
|
||||
* path, so the workspace-root ACE materializes once per workspace per
|
||||
* machine and every later provision hits the exact-ACE skip — the
|
||||
* grant-reuse story the per-session random SID paid a full tree propagation
|
||||
* per session for. Each private temp directory instead receives its own SID,
|
||||
* so sibling sessions sharing a workspace cannot enter one another's temp
|
||||
* trees. Unlike the POC, every API failure throws with the API
|
||||
* name and exact Win32 code; a child is NEVER spawned unrestricted.
|
||||
*
|
||||
@@ -42,7 +43,7 @@
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync, statSync } from 'node:fs'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
|
||||
|
||||
@@ -55,12 +56,10 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
export { AclWriteGrant } from './grant.ts'
|
||||
export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts'
|
||||
import { directoryWriteSid } from './workspace-sid.ts'
|
||||
|
||||
export { tempWriteSid, directoryWriteSid } from './workspace-sid.ts'
|
||||
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
|
||||
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts'
|
||||
/** Construction options: the writable-directory and private-temp allowlists and their distinct SID identities. */
|
||||
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
|
||||
export interface AclSandboxOptions {
|
||||
/** Directories the confined child may write into (must exist and be caller-owned). */
|
||||
writableDirs: readonly string[]
|
||||
@@ -70,11 +69,19 @@ export interface AclSandboxOptions {
|
||||
* root is never an implicit grant. Read-only accepts only null/undefined.
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
* The write SID forming the workspace-write allowlist: REQUIRED under
|
||||
* workspace-write, ignored (and must be absent) under read-only. Callers
|
||||
* derive it from the workspace via {@link workspaceWriteSid} — the identity
|
||||
* is per workspace, not per sandbox instance, so the workspace-root ACE
|
||||
* outlives every instance and later provisions hit the exact-ACE skip.
|
||||
*/
|
||||
writeSid?: string
|
||||
/**
|
||||
* The private temp directory's write SID. Required whenever
|
||||
* workspace-write grants a temp directory, absent otherwise. It must be
|
||||
* distinct from every directory write SID, so sibling sessions sharing a workspace
|
||||
* cannot use a standing directory capability in one another's temp tree.
|
||||
* distinct from {@link writeSid}, so sibling sessions sharing a workspace
|
||||
* cannot use the standing workspace capability in one another's temp tree.
|
||||
*/
|
||||
tempWriteSid?: string
|
||||
/**
|
||||
@@ -88,7 +95,7 @@ export interface AclSandboxOptions {
|
||||
/**
|
||||
* Whether this instance owns its DACL grants (default true). False means
|
||||
* the CALLER has already materialized the ACEs (the sandbox seam's
|
||||
* directory/temp capability lifecycle): init()/dispose() skip grant/revoke entirely —
|
||||
* workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely —
|
||||
* the caller holds the grants for its own lifetime and revokes them.
|
||||
*/
|
||||
manageDacls?: boolean
|
||||
@@ -148,16 +155,16 @@ function freeSidBestEffort(
|
||||
* One write-restricted sandbox instance: token + write-SID grants + spawn.
|
||||
* `init()` is fail-closed — any Win32 failure revokes the revocable (temp)
|
||||
* grants and throws; `dispose()` revokes the temp grants, leaves the
|
||||
* standing directory ACEs in place (the cross-instance reuse cache), frees
|
||||
* standing workspace ACEs in place (the cross-instance reuse cache), frees
|
||||
* every allocation, and reports every cleanup failure. With
|
||||
* `manageDacls: false` the caller owns the grants (the sandbox seam's grant
|
||||
* reuse): init() applies none and dispose() revokes none.
|
||||
*/
|
||||
export class AclSandbox {
|
||||
/** Canonical, deduplicated writable directories (constructor-validated). */
|
||||
readonly writableDirs: readonly string[]
|
||||
/** The independently derived write SIDs, in the same order as writableDirs; empty under read-only. */
|
||||
readonly writeSids: readonly string[]
|
||||
/** Absolute writable directories (constructor-validated). */
|
||||
readonly writableDirs: string[]
|
||||
/** The workspace SID string whose ACEs form the workspace allowlist. */
|
||||
readonly writeSid: string | undefined
|
||||
/** The private temp directory's write SID (workspace-write with temp only). */
|
||||
readonly tempWriteSid: string | undefined
|
||||
/** The file-effect mode — the restricted token's restricting-SID list selection. */
|
||||
@@ -167,7 +174,7 @@ export class AclSandbox {
|
||||
private tempDirResolved: string | null | undefined
|
||||
private api: Win32Bindings | undefined
|
||||
private token: NativePtr | undefined
|
||||
private readonly directorySidPtrs = new Map<string, NativePtr>()
|
||||
private writeSidPtr: NativePtr | undefined
|
||||
private tempWriteSidPtr: NativePtr | undefined
|
||||
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */
|
||||
private sidAllocations: NativePtr[] = []
|
||||
@@ -176,23 +183,26 @@ export class AclSandbox {
|
||||
constructor(options: AclSandboxOptions) {
|
||||
this.mode = options.mode
|
||||
this.manageDacls = options.manageDacls ?? true
|
||||
this.writableDirs = [...new Set(options.writableDirs.map((directory) => {
|
||||
this.writableDirs = options.writableDirs.map((directory) => {
|
||||
const absolute = resolve(directory)
|
||||
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
|
||||
throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`)
|
||||
}
|
||||
return realpathSync.native(absolute)
|
||||
}))]
|
||||
return absolute
|
||||
})
|
||||
this.tempDirOption = options.tempDir
|
||||
this.writeSids = this.mode === 'workspace-write' ? this.writableDirs.map(directoryWriteSid) : []
|
||||
this.writeSid = options.writeSid
|
||||
this.tempWriteSid = options.tempWriteSid
|
||||
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
|
||||
}
|
||||
if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) {
|
||||
throw new Error('AclSandbox read-only does not accept a temp directory')
|
||||
}
|
||||
if (this.mode === 'read-only' && this.tempWriteSid !== undefined) {
|
||||
if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) {
|
||||
throw new Error('AclSandbox read-only does not accept write SIDs')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) {
|
||||
@@ -201,7 +211,7 @@ export class AclSandbox {
|
||||
if (this.tempDirOption === null && this.tempWriteSid !== undefined) {
|
||||
throw new Error('AclSandbox temp write SID requires a temp directory')
|
||||
}
|
||||
if (this.tempWriteSid !== undefined && this.writeSids.includes(this.tempWriteSid)) {
|
||||
if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) {
|
||||
throw new Error('AclSandbox workspace and temp write SIDs must be distinct')
|
||||
}
|
||||
}
|
||||
@@ -228,9 +238,7 @@ export class AclSandbox {
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid)
|
||||
return parsedSid
|
||||
}
|
||||
for (const [index, sid] of this.writeSids.entries()) {
|
||||
this.directorySidPtrs.set(this.writableDirs[index] as string, parseSid(sid))
|
||||
}
|
||||
this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid)
|
||||
this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid)
|
||||
|
||||
const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption
|
||||
@@ -248,7 +256,7 @@ export class AclSandbox {
|
||||
// manageDacls: false — the caller (the sandbox seam's grant) already
|
||||
// materialized the ACEs; this instance must neither add nor remove any.
|
||||
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
|
||||
// per-directory reuse cache — dispose() never revokes them, or the next
|
||||
// per-workspace reuse cache — dispose() never revokes them, or the next
|
||||
// provision would re-propagate the whole tree) and the temp ACE is
|
||||
// REVOCABLE (dispose() removes it before the private directory is
|
||||
// deleted; the ambient temp root is never granted).
|
||||
@@ -258,9 +266,9 @@ export class AclSandbox {
|
||||
this.sidAllocations.push(lowLabelSid, worldSid)
|
||||
|
||||
if (this.manageDacls) {
|
||||
if (this.mode === 'workspace-write') {
|
||||
for (const [path, sidPtr] of this.directorySidPtrs) {
|
||||
grantWrite(api, path, sidPtr, lowLabelSid, worldSid)
|
||||
if (this.writeSidPtr !== undefined) {
|
||||
for (const path of this.writableDirs) {
|
||||
grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid)
|
||||
}
|
||||
if (tempDir !== null && this.tempWriteSidPtr !== undefined) {
|
||||
// Record BEFORE granting: grantWrite can throw after a successful
|
||||
@@ -273,8 +281,7 @@ export class AclSandbox {
|
||||
}
|
||||
const logonSid = findLogonSid(api, currentToken)
|
||||
this.sidAllocations.push(logonSid)
|
||||
const writeSids = [...this.directorySidPtrs.values()]
|
||||
if (this.tempWriteSidPtr !== undefined) writeSids.push(this.tempWriteSidPtr)
|
||||
const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined)
|
||||
restrictedToken = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSids,
|
||||
{ world: worldSid },
|
||||
@@ -289,18 +296,18 @@ export class AclSandbox {
|
||||
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
|
||||
// piped-stdio grandchild spawn. Merge a full-access ACE for a
|
||||
// restricting SID (the PRIVATE temp SID when present, otherwise the
|
||||
// first directory SID, or Everyone when neither is present): new-object creation
|
||||
// workspace SID, or Everyone under read-only): new-object creation
|
||||
// stays gated by the parent object's DACL, while the new object's own
|
||||
// DACL passes pass-2. Choosing the temp SID prevents default-DACL
|
||||
// objects in one session's temp tree from acquiring the shared
|
||||
// directory capability.
|
||||
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.directorySidPtrs.values().next().value ?? worldSid)
|
||||
// workspace capability.
|
||||
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid)
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
currentTokenOpen = false
|
||||
this.api = api
|
||||
} catch (error) {
|
||||
// Fail-closed cleanup: never leave a revocable (temp) grant or SID
|
||||
// allocation behind a failed init. Standing directory ACEs are NOT
|
||||
// allocation behind a failed init. Standing workspace ACEs are NOT
|
||||
// revoked — they are the intended end state (the reuse cache), not an
|
||||
// error artifact.
|
||||
const cleanupFailures: unknown[] = []
|
||||
@@ -317,13 +324,14 @@ export class AclSandbox {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', cleanupFailures)
|
||||
freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', cleanupFailures)
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
freeSidBestEffort(api, sidPtr, label, cleanupFailures)
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures)
|
||||
}
|
||||
this.token = undefined
|
||||
this.directorySidPtrs.clear()
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.tempDirResolved = undefined
|
||||
this.grantedPaths = []
|
||||
@@ -395,7 +403,7 @@ export class AclSandbox {
|
||||
|
||||
/**
|
||||
* Revoke the revocable (temp) grants, free the SID, close the token; the
|
||||
* standing directory ACEs stay (the reuse cache). Reports every cleanup
|
||||
* standing workspace ACEs stay (the reuse cache). Reports every cleanup
|
||||
* failure.
|
||||
*/
|
||||
dispose(): void {
|
||||
@@ -411,8 +419,9 @@ export class AclSandbox {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', failures)
|
||||
freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', failures)
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
freeSidBestEffort(api, sidPtr, label, failures)
|
||||
}
|
||||
const token = this.token
|
||||
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
|
||||
has its token; the guard mirrors the write-SID guard. */
|
||||
@@ -428,7 +437,7 @@ export class AclSandbox {
|
||||
}
|
||||
this.api = undefined
|
||||
this.token = undefined
|
||||
this.directorySidPtrs.clear()
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.grantedPaths = []
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
|
||||
* seam spawns in place of the caller's command. It creates the
|
||||
* WRITE_RESTRICTED token with the per-directory write-SID allowlist, spawns the
|
||||
* WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
|
||||
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
|
||||
* straight through), mirrors the child's exit code, and revokes its temp
|
||||
* grant on exit (directory ACEs stay standing as the reuse cache).
|
||||
* grant on exit (workspace ACEs stay standing as the reuse cache).
|
||||
*
|
||||
* Stable argv contract (the seam builds it; a native-exe replacement would
|
||||
* keep the same contract):
|
||||
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
|
||||
* '--mode', <read-only|workspace-write>, ['--writable-root', <dir>]...,
|
||||
* ['--write-sid', <S-1-4-…>]..., ['--temp-write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
* '--mode', <read-only|workspace-write>,
|
||||
* ['--write-sid', <S-1-4-…>,
|
||||
* '--temp-write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
*
|
||||
* Modes:
|
||||
* - workspace-write: each writable directory and private temp carry distinct
|
||||
* - workspace-write: the workspace and temp directories carry distinct
|
||||
* capability-SID Write grants; other ACL-addressable writes are denied
|
||||
* except for the documented Everyone and hard-link boundaries.
|
||||
* - read-only: no capability-SID grants; the restricting list carries no
|
||||
@@ -23,11 +24,11 @@
|
||||
* Public tree writes are denied); the two lists share the keep-alive group
|
||||
* (logon SID, EVERYONE) and differ only by the capabilities.
|
||||
*
|
||||
* Repeated `--write-sid` plus `--temp-write-sid`: the seam's grant contract — the
|
||||
* CALLER has already materialized per-directory and private-temp ACEs
|
||||
* `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
|
||||
* CALLER has already materialized distinct workspace and private-temp ACEs
|
||||
* and owns their revocation, so the runner neither grants nor revokes
|
||||
* (`manageDacls: false`). Each SID is checked against its owning directory path.
|
||||
* Without these SID flags (standalone/agentless use), workspace-write treats
|
||||
* (`manageDacls: false`). Both values are checked against their owning paths.
|
||||
* Without the pair (standalone/agentless use), workspace-write treats
|
||||
* `--temp` as a ROOT, creates a random private child directory, derives its
|
||||
* own temp SID, and removes that directory after the child exits. In both
|
||||
* flows the runner rewrites TMP/TEMP in its OWN environment to the private
|
||||
@@ -44,12 +45,12 @@
|
||||
*/
|
||||
|
||||
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
|
||||
import { closeSync, existsSync, mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs'
|
||||
import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { win32 } from './ffi.ts'
|
||||
import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts'
|
||||
import { tempWriteSid, directoryWriteSid } from './workspace-sid.ts'
|
||||
import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
|
||||
const RUNNER_SIGNATURE = 'windows-acl-run'
|
||||
const RUNNER_FAILURE_EXIT = 127
|
||||
@@ -66,19 +67,17 @@ interface ParsedArgs {
|
||||
workspace: string
|
||||
temp: string
|
||||
mode: 'read-only' | 'workspace-write'
|
||||
writableRoots: string[]
|
||||
writeSids: string[]
|
||||
writeSid: string | undefined
|
||||
tempWriteSid: string | undefined
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
function parseArgs(raw: string[]): ParsedArgs {
|
||||
const writableRoots: string[] = []
|
||||
let workspace: string | undefined
|
||||
let temp: string | undefined
|
||||
let mode: string | undefined
|
||||
const writeSids: string[] = []
|
||||
let writeSid: string | undefined
|
||||
let parsedTempWriteSid: string | undefined
|
||||
let index = 0
|
||||
for (; index < raw.length; index++) {
|
||||
@@ -92,10 +91,9 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
if (value === undefined) fail(`missing value after ${token}`)
|
||||
switch (token) {
|
||||
case '--workspace': workspace = value; break
|
||||
case '--writable-root': writableRoots.push(value); break
|
||||
case '--temp': temp = value; break
|
||||
case '--mode': mode = value; break
|
||||
case '--write-sid': writeSids.push(value); break
|
||||
case '--write-sid': writeSid = value; break
|
||||
case '--temp-write-sid': parsedTempWriteSid = value; break
|
||||
default: fail(`unknown argument: ${token}`)
|
||||
}
|
||||
@@ -106,7 +104,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
const argv = raw.slice(index)
|
||||
const command = argv[0]
|
||||
if (command === undefined) fail('missing command after --')
|
||||
return { workspace, temp, mode, writableRoots, writeSids, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
|
||||
return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
|
||||
}
|
||||
|
||||
function requireDirectory(label: string, path: string): void {
|
||||
@@ -121,21 +119,16 @@ async function main(): Promise<number> {
|
||||
// a bogus root must fail loudly at the runner boundary, never mid-child.
|
||||
requireDirectory('--workspace', parsed.workspace)
|
||||
requireDirectory('--temp', parsed.temp)
|
||||
for (const root of parsed.writableRoots) requireDirectory('--writable-root', root)
|
||||
const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots].map(directory => realpathSync.native(directory)))]
|
||||
|
||||
const seamManaged = parsed.writeSids.length > 0 || parsed.tempWriteSid !== undefined
|
||||
if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) {
|
||||
fail('read-only does not accept --writable-root')
|
||||
}
|
||||
const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
|
||||
if (parsed.mode === 'read-only' && seamManaged) {
|
||||
fail('read-only does not accept --write-sid or --temp-write-sid')
|
||||
}
|
||||
if (parsed.mode === 'workspace-write' && (parsed.writeSids.length === 0) !== (parsed.tempWriteSid === undefined)) {
|
||||
if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
|
||||
fail('workspace-write requires --write-sid and --temp-write-sid together')
|
||||
}
|
||||
if (parsed.mode === 'workspace-write') {
|
||||
for (const root of writableDirs) assertTempRootOutsideWorkspace(root, parsed.temp)
|
||||
assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp)
|
||||
}
|
||||
|
||||
const api = await win32()
|
||||
@@ -151,13 +144,12 @@ async function main(): Promise<number> {
|
||||
let initialized = false
|
||||
try {
|
||||
let privateTempDir: string | null = null
|
||||
let writeSid: string | undefined
|
||||
let privateTempSid: string | undefined
|
||||
if (parsed.mode === 'workspace-write') {
|
||||
writeSid = workspaceWriteSid(parsed.workspace)
|
||||
if (seamManaged) {
|
||||
if (parsed.writeSids.length !== writableDirs.length
|
||||
|| writableDirs.some((directory, index) => parsed.writeSids[index] !== directoryWriteSid(directory))) {
|
||||
fail('--write-sid must match each writable directory in order')
|
||||
}
|
||||
if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace')
|
||||
privateTempDir = parsed.temp
|
||||
privateTempSid = tempWriteSid(privateTempDir)
|
||||
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
|
||||
@@ -168,9 +160,10 @@ async function main(): Promise<number> {
|
||||
}
|
||||
}
|
||||
sandbox = new AclSandbox({
|
||||
writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [],
|
||||
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
|
||||
tempDir: privateTempDir,
|
||||
mode: parsed.mode,
|
||||
...writeSid === undefined ? {} : { writeSid },
|
||||
...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid },
|
||||
manageDacls: !seamManaged,
|
||||
})
|
||||
|
||||
@@ -101,8 +101,7 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
|
||||
* (child stdio) therefore fails the write pass-2 check at creation
|
||||
* (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
|
||||
* piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
|
||||
* private-temp SID, otherwise the first directory SID, or Everyone when
|
||||
* neither is present), so each new
|
||||
* write SID under workspace-write, Everyone under read-only), so each new
|
||||
* object's own DACL passes pass-2 while object creation itself stays gated by
|
||||
* the parent container's DACL (files outside the granted trees remain
|
||||
* uncreatable). Fails closed: any Win32 failure throws before the spawn.
|
||||
@@ -183,7 +182,7 @@ export interface RestrictingSidSet {
|
||||
* Create the write-restricted token with the mode-selected restricting list
|
||||
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
|
||||
* - read-only: [logon SID, EVERYONE]
|
||||
* - workspace-write: [logon SID, EVERYONE, ...directory SIDs, optional temp SID]
|
||||
* - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID]
|
||||
*
|
||||
* The logon SID + EVERYONE keep-alive group is shared by both modes: early
|
||||
* DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
|
||||
@@ -192,7 +191,7 @@ export interface RestrictingSidSet {
|
||||
* from an earlier workspace-write period (a `/permission` mode downgrade, or
|
||||
* a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
|
||||
* pass-2 check grants only what the restricting list carries, keeping that
|
||||
* directory grant inert under read-only while the unrevoked ACE keeps the
|
||||
* workspace grant inert under read-only while the unrevoked ACE keeps the
|
||||
* re-upgrade free (the grant's exact-ACE skip — no re-propagation).
|
||||
* Everyone's own ambient grants remain the documented partial boundary.
|
||||
* Authenticated Users is absent from BOTH lists: the WMI
|
||||
@@ -208,7 +207,7 @@ export interface RestrictingSidSet {
|
||||
* @param api - the binding table.
|
||||
* @param currentToken - the process token to restrict.
|
||||
* @param logonSid - the copied logon session SID.
|
||||
* @param writeSids - the distinct write SIDs forming the directory and
|
||||
* @param writeSids - the distinct write SIDs forming the workspace and
|
||||
* optional temp allowlists (workspace-write only; empty under read-only).
|
||||
* @param known - the well-known SIDs entering the restricting list.
|
||||
* @param mode - selects the restricting list (workspace-write adds the capability SIDs).
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
/**
|
||||
* Stable write identities for individual canonical directories, with separate
|
||||
* identities for private temp directories.
|
||||
* The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived
|
||||
* from the canonical workspace path, whose ACEs form that workspace's write
|
||||
* allowlist. Every confined execution of the same workspace — across
|
||||
* sessions, server restarts, and calls — carries the SAME write SID, so the
|
||||
* workspace-root ACE materializes once per workspace per machine (the
|
||||
* grant's exact-ACE skip then makes every later provision O(1)) instead of
|
||||
* once per session. The SID's power is defined solely by the ACEs that name
|
||||
* it (which exist only on the workspace tree and the session's private temp
|
||||
* directory), and only tokens minted for that workspace carry it — the SID
|
||||
* string itself is not a secret. Temporary directories use a separate,
|
||||
* per-directory identity from {@link tempWriteSid}; sharing the workspace
|
||||
* identity with temp would let sibling sessions write one another's temp
|
||||
* trees.
|
||||
*
|
||||
* The input MUST be the canonical workspace path (`realpathSync.native` on
|
||||
* Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
|
||||
* canonicalization converges case/alias spellings, so two spellings of one
|
||||
* workspace derive one SID; an as-spelled fallback path would mint a second
|
||||
* identity for the same directory (self-healing, at the cost of one extra
|
||||
* tree propagation). Renaming the workspace directory derives a new SID —
|
||||
* the old standing ACEs are inert residue, and the next session re-propagates
|
||||
* once.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Derive one directory's write SID. Grant this SID only on that directory;
|
||||
* executions carry the SIDs of all directories they are allowed to write.
|
||||
* @param directory - the canonical directory path.
|
||||
* @returns the deterministic SDDL string, preserving existing workspace identities.
|
||||
* Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
|
||||
* matching the workspace-capability shape the token and ACE layers carry).
|
||||
* @param workspaceRoot - the canonical workspace path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
export function directoryWriteSid(directory: string): string {
|
||||
const digest = createHash('sha256').update(directory, 'utf8').digest()
|
||||
export function workspaceWriteSid(workspaceRoot: string): string {
|
||||
const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest()
|
||||
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
|
||||
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
|
||||
return `S-1-4-${first}-${second}`
|
||||
@@ -22,7 +42,7 @@ export function directoryWriteSid(directory: string): string {
|
||||
/**
|
||||
* Derive one private temp directory's write SID. The random directory path
|
||||
* is the capability identity; a fixed third subauthority domain-separates
|
||||
* the result from every two-subauthority directory SID.
|
||||
* the result from every two-subauthority workspace SID.
|
||||
* @param tempDir - the private temp directory's absolute path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
* whose per-test lock file is removed in cleanup.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts'
|
||||
import { AclSandbox, directoryWriteSid } from '../src/index.ts'
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts'
|
||||
import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
@@ -209,7 +209,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')))
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
@@ -364,11 +364,11 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('interleaved sandbox instances reuse one standing directory ACE', async () => {
|
||||
it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
|
||||
const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
|
||||
const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
|
||||
const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
|
||||
await sandboxA.init()
|
||||
await sandboxB.init()
|
||||
// Workspace ACEs are STANDING: dispose frees the instance's SID
|
||||
@@ -377,29 +377,8 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
sandboxA.dispose()
|
||||
sandboxB.dispose()
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.filter(ace => ace.sid === directoryWriteSid(dir))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shares a directory capability across sandbox sets without granting it on their other directories', async () => {
|
||||
const api = await win32()
|
||||
const workspaceA = scratch()
|
||||
const workspaceB = scratch()
|
||||
const memory = scratch()
|
||||
const sandboxA = new AclSandbox({ writableDirs: [workspaceA, memory], tempDir: null, mode: 'workspace-write' })
|
||||
const sandboxB = new AclSandbox({ writableDirs: [workspaceB, memory], tempDir: null, mode: 'workspace-write' })
|
||||
try {
|
||||
await sandboxA.init()
|
||||
await sandboxB.init()
|
||||
const dirs = [workspaceA, workspaceB, memory]
|
||||
const directorySids = dirs.map(directoryWriteSid)
|
||||
for (const dir of dirs) {
|
||||
const granted = readDirectAces(api, dir).filter(ace => directorySids.includes(ace.sid))
|
||||
expect(granted.map(ace => ace.sid)).toEqual([directoryWriteSid(dir)])
|
||||
}
|
||||
} finally {
|
||||
sandboxA.dispose()
|
||||
sandboxB.dispose()
|
||||
}
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true)
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => {
|
||||
@@ -409,13 +388,14 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceDir],
|
||||
tempDir,
|
||||
writeSid: 'S-1-4-9000-3',
|
||||
tempWriteSid: 'S-1-4-9000-3-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
sandbox.dispose()
|
||||
const workspaceAces = readDirectAces(api, workspaceDir)
|
||||
expect(workspaceAces.some(ace => ace.sid === directoryWriteSid(workspaceDir))).toBe(true)
|
||||
expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true)
|
||||
expect(readLabelAces(api, workspaceDir)).toEqual([
|
||||
{ sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP },
|
||||
])
|
||||
@@ -427,12 +407,13 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
it('rejects an overlapping private temp directory before applying either capability', async () => {
|
||||
const workspaceDir = scratch()
|
||||
const nestedTemp = join(workspaceDir, 'temp')
|
||||
const writeSid = directoryWriteSid(workspaceDir)
|
||||
const writeSid = 'S-1-4-9000-30'
|
||||
const privateTempSid = 'S-1-4-9000-30-1'
|
||||
mkdirSync(nestedTemp)
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceDir],
|
||||
tempDir: nestedTemp,
|
||||
writeSid,
|
||||
tempWriteSid: privateTempSid,
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -443,7 +424,21 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write requires a nonempty token capability list', () => {
|
||||
it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
|
||||
const dir = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))
|
||||
.toThrow(/requires a write SID/)
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
|
||||
.toThrow(/requires an explicit private temp directory or null/)
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
|
||||
.toThrow(/requires a temp write SID/)
|
||||
expect(() => new AclSandbox({
|
||||
writableDirs: [dir],
|
||||
tempDir: dir,
|
||||
writeSid: 'S-1-4-1-1',
|
||||
tempWriteSid: 'S-1-4-1-1',
|
||||
mode: 'workspace-write',
|
||||
})).toThrow(/must be distinct/)
|
||||
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write'))
|
||||
.toThrow(/requires at least one write SID/)
|
||||
})
|
||||
@@ -502,10 +497,10 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' })
|
||||
try {
|
||||
await sandbox.init()
|
||||
const grant = readDirectAces(api, dir).find(ace => ace.sid === sandbox.writeSids[0])
|
||||
const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5')
|
||||
expect(grant).toBeDefined()
|
||||
const mask = grant?.mask ?? 0
|
||||
expect(mask).toBe(abi.GRANT_MASK)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -53,7 +53,6 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
|
||||
// grant stays exactly one ACE (the reuse across sessions/restarts).
|
||||
grant.add(dir)
|
||||
grant.add(standingDir, true)
|
||||
expect(grant.paths).toEqual([standingDir, dir])
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-77')
|
||||
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
|
||||
grant.dispose()
|
||||
@@ -61,20 +60,6 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
|
||||
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
|
||||
})
|
||||
|
||||
it('restores a standing ACE on a recreated directory without duplicating its tracked path', () => {
|
||||
const dir = scratch()
|
||||
const grant = AclWriteGrant.create('S-1-4-9000-80')
|
||||
try {
|
||||
grant.add(dir, true)
|
||||
rmSync(dir, { recursive: true })
|
||||
mkdirSync(dir)
|
||||
expect(icaclsText(dir)).not.toContain(grant.writeSid)
|
||||
grant.add(dir, true)
|
||||
expect(icaclsText(dir)).toContain(grant.writeSid)
|
||||
expect(grant.paths).toEqual([dir])
|
||||
} finally { grant.dispose() }
|
||||
})
|
||||
|
||||
it('two grants with different SIDs coexist and revoke independently', () => {
|
||||
const dir = scratch()
|
||||
const grantA = AclWriteGrant.create('S-1-4-9000-78')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* acl.spec.ts and runner.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
|
||||
@@ -18,7 +18,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { AclSandbox, directoryWriteSid } from '../src/index.ts'
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
@@ -29,7 +29,6 @@ type MockFn = ReturnType<typeof vi.fn>
|
||||
interface HappyStubs {
|
||||
api: Win32Bindings
|
||||
setNamedSecurityInfoW: MockFn
|
||||
setEntriesInAclW: MockFn
|
||||
convertStringSidToSidW: MockFn
|
||||
closeHandle: MockFn
|
||||
localFree: MockFn
|
||||
@@ -179,7 +178,7 @@ function happyStubs(): HappyStubs {
|
||||
localFree, closeHandle, getLastError, formatMessageW,
|
||||
})
|
||||
return {
|
||||
api, setNamedSecurityInfoW, setEntriesInAclW, convertStringSidToSidW, closeHandle, localFree,
|
||||
api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
|
||||
createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce,
|
||||
}
|
||||
}
|
||||
@@ -198,46 +197,28 @@ describe('AclSandbox constructor validation', () => {
|
||||
it('resolves relative writable directories to absolute paths', () => {
|
||||
const dir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
|
||||
expect(sandbox.writableDirs).toEqual([realpathSync.native(dir)])
|
||||
expect(sandbox.writableDirs).toEqual([resolve(dir)])
|
||||
expect(sandbox.mode).toBe('read-only')
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives one SID per canonical directory and keeps read-only free of directory capabilities', () => {
|
||||
const workspace = scratch()
|
||||
const memory = scratch()
|
||||
const dirs = [workspace, memory, workspace]
|
||||
const sandbox = new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'workspace-write' })
|
||||
const canonical = [workspace, memory].map(path => realpathSync.native(path))
|
||||
expect(sandbox.writableDirs).toEqual(canonical)
|
||||
expect(sandbox.writeSids).toEqual(canonical.map(directoryWriteSid))
|
||||
expect(new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'read-only' }).writeSids).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects temp authority under read-only', () => {
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept a temp directory/u)
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept write SIDs/u)
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept write SIDs/u)
|
||||
})
|
||||
|
||||
it('requires a distinct SID for private temp writes', () => {
|
||||
const workspace = realpathSync.native(scratch())
|
||||
const temp = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'workspace-write' }))
|
||||
.toThrow(/requires a temp write SID/u)
|
||||
expect(() => new AclSandbox({
|
||||
writableDirs: [workspace], tempDir: temp, tempWriteSid: directoryWriteSid(workspace), mode: 'workspace-write',
|
||||
})).toThrow(/must be distinct/u)
|
||||
})
|
||||
|
||||
it('rejects a temp SID when temp writes are disabled', () => {
|
||||
const workspace = scratch()
|
||||
expect(() => new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: null,
|
||||
writeSid: 'S-1-4-9000-2',
|
||||
tempWriteSid: 'S-1-4-9000-2-1',
|
||||
mode: 'workspace-write',
|
||||
})).toThrow(/temp write SID requires a temp directory/u)
|
||||
@@ -252,6 +233,7 @@ describe('AclSandbox init', () => {
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-1',
|
||||
tempWriteSid: 'S-1-4-9000-1-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -260,50 +242,16 @@ describe('AclSandbox init', () => {
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([true, false])('places directory SIDs in the token and selects the default DACL (private temp: %s)', async (privateTemp) => {
|
||||
const { convertStringSidToSidW, createRestrictedToken, setEntriesInAclW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const memory = scratch()
|
||||
const temp = privateTemp ? scratch() : null
|
||||
const parsed: bigint[] = []
|
||||
convertStringSidToSidW.mockImplementation((_sid: string, slot: NativePtr) => {
|
||||
const pointer = BigInt(101 + parsed.length)
|
||||
parsed.push(pointer)
|
||||
koffi.encode(slot, PVOID, pointer)
|
||||
return 1
|
||||
})
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace, memory], tempDir: temp, mode: 'workspace-write',
|
||||
...(privateTemp ? { tempWriteSid: 'S-1-4-9000-1-1' } : {}),
|
||||
})
|
||||
try {
|
||||
await sandbox.init()
|
||||
const expectedSids = [...sandbox.writeSids]
|
||||
if (sandbox.tempWriteSid !== undefined) expectedSids.push(sandbox.tempWriteSid)
|
||||
expect(convertStringSidToSidW).toHaveBeenCalledTimes(expectedSids.length)
|
||||
expectedSids.forEach((sid, index) => {
|
||||
expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything())
|
||||
})
|
||||
const call = createRestrictedToken.mock.calls[0]!
|
||||
expect(call[6]).toBe(expectedSids.length + 2)
|
||||
const entries = call[7] as Buffer
|
||||
expect(parsed.map((_pointer, index) => entries.readBigUInt64LE((index + 2) * abi.SID_AND_ATTRIBUTES_SIZE))).toEqual(parsed)
|
||||
const defaultDaclMerge = setEntriesInAclW.mock.calls.find(call => call[2] === 88n)!
|
||||
const defaultEntry = defaultDaclMerge[1] as Buffer
|
||||
expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[privateTemp ? 2 : 0])
|
||||
} finally { sandbox.dispose() }
|
||||
})
|
||||
|
||||
it('requires an explicit private temp directory or null under workspace-write', () => {
|
||||
const workspace = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], mode: 'workspace-write' }))
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
|
||||
.toThrow(/requires an explicit private temp directory or null/u)
|
||||
})
|
||||
|
||||
it('applies no grants when the temp dir option is null', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
|
||||
})
|
||||
@@ -313,6 +261,7 @@ describe('AclSandbox init', () => {
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: join(scratch(), 'missing'),
|
||||
writeSid: 'S-1-4-9000-4',
|
||||
tempWriteSid: 'S-1-4-9000-4-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -320,14 +269,11 @@ describe('AclSandbox init', () => {
|
||||
})
|
||||
|
||||
it('builds a read-only token without parsing a write SID or applying grants', async () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW, createRestrictedToken } = state.stubs as HappyStubs
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const memory = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'read-only' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
|
||||
await sandbox.init()
|
||||
expect(convertStringSidToSidW).not.toHaveBeenCalled()
|
||||
expect(createRestrictedToken.mock.calls[0]![6]).toBe(2)
|
||||
expect(sandbox.writeSids).toEqual([])
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
|
||||
})
|
||||
@@ -335,7 +281,7 @@ describe('AclSandbox init', () => {
|
||||
it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write', manageDacls: false })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
|
||||
await sandbox.init()
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
|
||||
@@ -343,7 +289,7 @@ describe('AclSandbox init', () => {
|
||||
|
||||
it('refuses a second init on the same instance', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
|
||||
})
|
||||
@@ -352,44 +298,23 @@ describe('AclSandbox init', () => {
|
||||
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockReturnValue(0)
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('frees earlier directory SIDs when parsing a later directory SID fails', async () => {
|
||||
const { convertStringSidToSidW, localFree, setNamedSecurityInfoW, closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const memory = scratch()
|
||||
const firstSid = 101n as NativePtr
|
||||
convertStringSidToSidW.mockImplementationOnce((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, firstSid)
|
||||
return 1
|
||||
}).mockReturnValueOnce(0)
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
|
||||
expect(convertStringSidToSidW).toHaveBeenCalledTimes(sandbox.writeSids.length)
|
||||
sandbox.writeSids.forEach((sid, index) => {
|
||||
expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything())
|
||||
})
|
||||
expect(localFree).toHaveBeenCalledWith(firstSid)
|
||||
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
|
||||
expect(closeHandle).toHaveBeenCalledWith(2n)
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
|
||||
const { convertStringSidToSidW } = state.stubs as HappyStubs
|
||||
convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
|
||||
})
|
||||
|
||||
it('aggregates failed current and restricted token closes after init', async () => {
|
||||
const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
|
||||
const restrictedToken = 99n
|
||||
createRestrictedToken.mockImplementation((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
@@ -436,6 +361,7 @@ describe('AclSandbox init', () => {
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-10',
|
||||
tempWriteSid: 'S-1-4-9000-10-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -465,13 +391,13 @@ describe('AclSandbox spawn', () => {
|
||||
|
||||
it('refuses to spawn before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
|
||||
expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
|
||||
})
|
||||
|
||||
it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
|
||||
expect(child.pid).toBe(1234)
|
||||
@@ -484,7 +410,7 @@ describe('AclSandbox spawn', () => {
|
||||
it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
|
||||
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
|
||||
@@ -494,7 +420,7 @@ describe('AclSandbox spawn', () => {
|
||||
it('inherit spawn reports a failed close of the kill-on-close job', async () => {
|
||||
const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
let jobHandle = 0n
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
|
||||
@@ -507,7 +433,7 @@ describe('AclSandbox spawn', () => {
|
||||
describe('AclSandbox dispose', () => {
|
||||
it('is a no-op before init', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
|
||||
expect(() => { sandbox.dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -518,6 +444,7 @@ describe('AclSandbox dispose', () => {
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-16',
|
||||
tempWriteSid: 'S-1-4-9000-16-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -529,7 +456,7 @@ describe('AclSandbox dispose', () => {
|
||||
it('aggregates SID and token cleanup failures into an AggregateError', async () => {
|
||||
const { localFree } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
localFree.mockReturnValue(1n)
|
||||
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
|
||||
@@ -538,7 +465,7 @@ describe('AclSandbox dispose', () => {
|
||||
it('reports a failed close of the restricted token', async () => {
|
||||
const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
|
||||
let restrictedToken = 0n
|
||||
createRestrictedToken.mockImplementation((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
|
||||
@@ -52,6 +52,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
|
||||
sandbox = new AclSandbox({
|
||||
writableDirs: [writableDir],
|
||||
tempDir: isolatedTemp,
|
||||
writeSid: 'S-1-4-9000-4',
|
||||
tempWriteSid: 'S-1-4-9000-4-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
@@ -91,15 +92,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('fails closed when the temp SID cannot be parsed (no unrestricted fallback)', async () => {
|
||||
it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => {
|
||||
// A malformed SID makes ConvertStringSidToSidW fail; init must throw
|
||||
// before any grant is applied and never spawn unrestricted.
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
|
||||
}, 15_000)
|
||||
|
||||
it('failed init clears provisional temp state before a retry', async () => {
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
const provisionalState = broken as unknown as { tempDirResolved: string | undefined }
|
||||
provisionalState.tempDirResolved = isolatedTemp
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { AclWriteGrant, tempWriteSid, directoryWriteSid } from '../src/index.ts'
|
||||
import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
|
||||
@@ -48,10 +48,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
let publicProbeDir: string | undefined
|
||||
|
||||
beforeAll(() => {
|
||||
scratchRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')))
|
||||
scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')))
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
@@ -107,65 +107,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('new processes use the exact writable set despite standing grants from a broader set', () => {
|
||||
const extra = mkdtempSync(join(scratchRoot, 'extra-set-'))
|
||||
const workspaceFile = join(writableDir, 'set-workspace.txt')
|
||||
const extraFile = join(extra, 'set-extra.txt')
|
||||
const probe = "const fs = require('node:fs'); for (const path of process.argv.slice(1)) { try { fs.writeFileSync(path, 'written'); console.log('ALLOW ' + path) } catch { console.log('DENY ' + path) } }"
|
||||
const expanded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
'--writable-root', extra,
|
||||
'--', process.execPath, '-e', probe, workspaceFile, extraFile,
|
||||
])
|
||||
expect(expanded.error).toBeUndefined()
|
||||
expect(expanded.signal).toBeNull()
|
||||
expect(expanded.status, expanded.stderr).toBe(0)
|
||||
expect(readFileSync(extraFile, 'utf8')).toBe('written')
|
||||
writeFileSync(extraFile, 'retained')
|
||||
|
||||
const narrowed = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
'--', process.execPath, '-e', probe, workspaceFile, extraFile,
|
||||
])
|
||||
expect(narrowed.error).toBeUndefined()
|
||||
expect(narrowed.signal).toBeNull()
|
||||
expect(narrowed.status, narrowed.stderr).toBe(0)
|
||||
expect(narrowed.stdout).toContain(`ALLOW ${workspaceFile}`)
|
||||
expect(narrowed.stdout).toContain(`DENY ${extraFile}`)
|
||||
expect(readFileSync(extraFile, 'utf8')).toBe('retained')
|
||||
}, 90_000)
|
||||
|
||||
it('accepts one provider-materialized SID per writable directory', () => {
|
||||
const extra = mkdtempSync(join(scratchRoot, 'managed-extra-'))
|
||||
const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-'))
|
||||
const writeSid = directoryWriteSid(writableDir)
|
||||
const extraSid = directoryWriteSid(extra)
|
||||
const privateTempSid = tempWriteSid(privateTemp)
|
||||
const directoryGrant = AclWriteGrant.create(writeSid)
|
||||
const extraGrant = AclWriteGrant.create(extraSid)
|
||||
const tempGrant = AclWriteGrant.create(privateTempSid)
|
||||
try {
|
||||
directoryGrant.add(writableDir, true)
|
||||
extraGrant.add(extra, true)
|
||||
tempGrant.add(privateTemp)
|
||||
const target = join(extra, 'managed-write.txt')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write',
|
||||
'--writable-root', extra, '--write-sid', writeSid, '--write-sid', extraSid, '--temp-write-sid', privateTempSid,
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target,
|
||||
])
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(target, 'utf8')).toBe('managed')
|
||||
} finally {
|
||||
directoryGrant.dispose()
|
||||
extraGrant.dispose()
|
||||
tempGrant.dispose()
|
||||
rmSync(privateTemp, { recursive: true, force: true })
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM availability follows the caller token', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
@@ -225,7 +166,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => {
|
||||
const seamWorkspace = join(scratchRoot, 'seam-workspace')
|
||||
mkdirSync(seamWorkspace)
|
||||
const writeSid = directoryWriteSid(seamWorkspace)
|
||||
const writeSid = workspaceWriteSid(seamWorkspace)
|
||||
const privateTemp = join(isolatedTemp, 'private-subdir')
|
||||
mkdirSync(privateTemp)
|
||||
const privateTempSid = tempWriteSid(privateTemp)
|
||||
@@ -261,7 +202,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}, 30_000)
|
||||
|
||||
it('temp capabilities isolate sibling sessions that share one workspace SID', () => {
|
||||
const writeSid = directoryWriteSid(writableDir)
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const tempA = join(isolatedTemp, 'session-a')
|
||||
const tempB = join(isolatedTemp, 'session-b')
|
||||
mkdirSync(tempA)
|
||||
@@ -392,7 +333,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
// it, so the workspace write is denied instead of leaking through the
|
||||
// standing ACE. The switch back reuses the SAME standing ACE: the
|
||||
// re-upgrade write lands without any re-grant.
|
||||
const writeSid = directoryWriteSid(writableDir)
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const privateTemp = join(isolatedTemp, 'mode-switch-temp')
|
||||
mkdirSync(privateTemp)
|
||||
const privateTempSid = tempWriteSid(privateTemp)
|
||||
@@ -509,7 +450,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
const otherTemp = join(scratchRoot, 'other-temp')
|
||||
mkdirSync(otherWorkspace)
|
||||
mkdirSync(otherTemp)
|
||||
const otherWorkspaceSid = directoryWriteSid(otherWorkspace)
|
||||
const otherWorkspaceSid = workspaceWriteSid(otherWorkspace)
|
||||
const otherTempSid = tempWriteSid(otherTemp)
|
||||
const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid)
|
||||
const otherTempGrant = AclWriteGrant.create(otherTempSid)
|
||||
@@ -520,7 +461,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
const ownTemp = join(scratchRoot, 'own-temp')
|
||||
mkdirSync(ownWorkspace)
|
||||
mkdirSync(ownTemp)
|
||||
const ownWorkspaceSid = directoryWriteSid(ownWorkspace)
|
||||
const ownWorkspaceSid = workspaceWriteSid(ownWorkspace)
|
||||
const ownTempSid = tempWriteSid(ownTemp)
|
||||
const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid)
|
||||
const ownTempGrant = AclWriteGrant.create(ownTempSid)
|
||||
@@ -597,7 +538,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
mkdirSync(child)
|
||||
writeFileSync(join(granted, 'file.txt'), 'x')
|
||||
writeFileSync(join(child, 'deep.txt'), 'x')
|
||||
const grant = AclWriteGrant.create(directoryWriteSid(granted))
|
||||
const grant = AclWriteGrant.create(workspaceWriteSid(granted))
|
||||
grant.add(granted, true)
|
||||
try {
|
||||
const probe = `
|
||||
@@ -632,7 +573,7 @@ TryOpen 'DIRECTORY' '${child}'
|
||||
// not strip the label the second one's child still writes through.
|
||||
const shared = join(scratchRoot, 'shared-root')
|
||||
mkdirSync(shared)
|
||||
const sidA = directoryWriteSid(shared)
|
||||
const sidA = workspaceWriteSid(shared)
|
||||
const sidB = `${sidA}-1`
|
||||
const grantA = AclWriteGrant.create(sidA)
|
||||
const grantB = AclWriteGrant.create(sidB)
|
||||
@@ -701,26 +642,11 @@ TryOpen 'DIRECTORY' '${child}'
|
||||
expect(result.stderr).toContain('windows-acl-run: ')
|
||||
}, 15_000)
|
||||
|
||||
it('read-only rejects writable-directory flags before starting the command', () => {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
|
||||
'--writable-root', worldWritableDir,
|
||||
'--', process.execPath, '-e', 'process.exit(99)',
|
||||
])
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.status, result.stderr).toBe(127)
|
||||
expect(result.stderr).toContain('read-only does not accept --writable-root')
|
||||
}, 90_000)
|
||||
|
||||
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
|
||||
const writeSid = directoryWriteSid(writableDir)
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const tempSid = tempWriteSid(isolatedTemp)
|
||||
const cases = [
|
||||
['--write-sid', writeSid],
|
||||
['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid],
|
||||
['--writable-root', worldWritableDir, '--write-sid', directoryWriteSid(worldWritableDir), '--write-sid', writeSid, '--temp-write-sid', tempSid],
|
||||
['--write-sid', writeSid, '--write-sid', directoryWriteSid(worldWritableDir), '--temp-write-sid', tempSid],
|
||||
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
|
||||
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
|
||||
]
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
/** Stable, distinct write identities for canonical directories and private temp paths. */
|
||||
/**
|
||||
* workspaceWriteSid tests: the per-workspace write identity is deterministic
|
||||
* (the same canonical path always derives the same SID — the property the
|
||||
* cross-session grant reuse rests on), capability-shaped, distinct across
|
||||
* workspaces, and byte-sensitive (the canonical path is the caller's
|
||||
* contract; an alias spelling derives a second identity, self-healing at
|
||||
* the cost of one extra tree propagation).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { directoryWriteSid, tempWriteSid } from '../src/index.ts'
|
||||
import { tempWriteSid, workspaceWriteSid } from '../src/index.ts'
|
||||
|
||||
describe('directoryWriteSid', () => {
|
||||
it('derives a stable capability-shaped SID per directory path', () => {
|
||||
const first = directoryWriteSid('C:\\Users\\agent\\repo')
|
||||
const second = directoryWriteSid('C:\\Users\\agent\\repo')
|
||||
describe('workspaceWriteSid', () => {
|
||||
it('derives a stable capability-shaped SID per workspace path', () => {
|
||||
const first = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
const second = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
expect(first).toBe(second)
|
||||
expect(first).toMatch(/^S-1-4-\d+-\d+$/u)
|
||||
})
|
||||
|
||||
it('derives distinct identities for distinct directories', () => {
|
||||
expect(directoryWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(directoryWriteSid('C:\\Users\\agent\\repo-b'))
|
||||
it('derives distinct identities for distinct workspaces', () => {
|
||||
expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b'))
|
||||
})
|
||||
|
||||
it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => {
|
||||
expect(directoryWriteSid('C:\\Repo')).not.toBe(directoryWriteSid('c:\\repo'))
|
||||
expect(directoryWriteSid('C:\\Repo\\')).not.toBe(directoryWriteSid('C:\\Repo'))
|
||||
expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo'))
|
||||
expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +34,7 @@ describe('tempWriteSid', () => {
|
||||
const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')
|
||||
expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
|
||||
expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u)
|
||||
expect(temp).not.toBe(directoryWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
|
||||
expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
|
||||
})
|
||||
|
||||
it('derives distinct capabilities for distinct private temp paths', () => {
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: c5540889017b6a7f
|
||||
zh: 679bfbd5254bf92d
|
||||
/deepseek-ai-dsh-sandbox/use-this-package/modes-and-enforcement:
|
||||
en: aabe9791ece99ec1
|
||||
zh: 1dbc86eabb3a5548
|
||||
en: d3c906ddb4c47cbc
|
||||
zh: d264b9103e3566d6
|
||||
/deepseek-ai-dsh-sandbox/use-this-package/denied-calls-and-escalation:
|
||||
en: 3960c33a1e322cf9
|
||||
zh: 4d85c962067fe7a1
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 1b418d41f571ab3d
|
||||
zh: 62b289cdd6f4d272
|
||||
/deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots:
|
||||
en: b5001b6dc9a0d258
|
||||
zh: 08020a052df6856c
|
||||
en: 6be9f7d4c65761d6
|
||||
zh: f00d9970ed58e3e0
|
||||
/deepseek-ai-dsh-sandbox/further-exploration:
|
||||
en: 33d09277fb15d59f
|
||||
zh: 920ffe66671b56f3
|
||||
|
||||
@@ -56,7 +56,7 @@ The mode names the file effects a command may perform; enforcement completeness
|
||||
| Mode | Effect |
|
||||
|---|---|
|
||||
| `read-only` | Denies writes except required sinks such as `/dev/null` |
|
||||
| `workspace-write` | Allows writes under the workspace root, explicitly configured additional roots, and a backend-defined temp area |
|
||||
| `workspace-write` | Allows writes under the workspace root, additional roots where supported, and a backend-defined temp area |
|
||||
| `danger-full-access` | Bypasses confinement; the consumer spawns its original argv |
|
||||
|
||||
Enforcement is reported per call: `full` means the backend governs every promised file effect, while `partial` means an active backend or older kernel ABI governs only a subset — the Windows ACL rung and older Landlock ABIs are the current partial cases, so a consumer that requires the absolute boundary can reject or surface them.
|
||||
@@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o
|
||||
|
||||
### Writable roots
|
||||
|
||||
`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md).
|
||||
`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. Windows subprocesses ignore additional roots; file tools still enforce them. See the [mode definitions](../../../docs/subsystems/sandbox.md).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ kind: "package-reference"
|
||||
| 模式 | 效果 |
|
||||
|---|---|
|
||||
| `read-only` | 拒绝写入,必需 sink(如 `/dev/null`)除外 |
|
||||
| `workspace-write` | 允许写入工作区根目录、显式配置的额外根目录及后端定义的临时区域 |
|
||||
| `workspace-write` | 允许写入工作区根目录、受支持的额外根目录及后端定义的临时区域 |
|
||||
| `danger-full-access` | 绕过隔离;消费方直接 spawn 原始 argv |
|
||||
|
||||
强制执行逐调用报告:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示活动后端或较旧的内核 ABI 只管辖子集——Windows ACL 档与较旧的 Landlock ABI 是当前的部分强制执行情形,需要绝对边界的消费方可以拒绝或向上暴露它们。
|
||||
@@ -100,7 +100,7 @@ kind: "package-reference"
|
||||
|
||||
### 可写根目录
|
||||
|
||||
`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
|
||||
`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。Windows 子进程忽略额外根目录;文件工具仍然执行这些授权。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export { canonicalPath, workspaceWritableRoots, writableRoots, SandboxInvalidRoo
|
||||
/**
|
||||
* File-effect policy for confined processes. `read-only` permits only required
|
||||
* sinks such as `/dev/null`; `workspace-write` also permits the workspace,
|
||||
* explicit extra roots, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* explicit extra roots where supported, and backend-defined temp areas; `danger-full-access` bypasses confinement. Network
|
||||
* and process visibility are outside this vocabulary.
|
||||
*/
|
||||
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
@@ -46,6 +46,7 @@ export interface SandboxExecutionPolicy {
|
||||
* Extra physical absolute directories `workspace-write` may also write under.
|
||||
* Absence means none. The enforcing host rejects missing, non-directory, and
|
||||
* symlinked entries on each call. Remote paths are checked on the remote host.
|
||||
* Windows process confinement ignores these; harness filesystem operations honor them.
|
||||
* These grant nothing under `read-only` or `danger-full-access`.
|
||||
*/
|
||||
additionalWritableRoots?: readonly string[]
|
||||
@@ -53,7 +54,7 @@ export interface SandboxExecutionPolicy {
|
||||
* Opaque identity of the calling session (the branded `dsh-session`
|
||||
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
|
||||
* each live session/workspace pair a random private temp directory and SID,
|
||||
* while standing grants are keyed by individual writable directories); absent
|
||||
* while the workspace SID and standing grant remain per-workspace); absent
|
||||
* for agentless calls, which fall back to per-call backend state.
|
||||
*/
|
||||
sessionId?: SessionId
|
||||
|
||||
@@ -70,9 +70,9 @@ export function canonicalPath(path: string): string {
|
||||
/**
|
||||
* The workspace-owned writable roots — the policy's workspace root plus its
|
||||
* explicit extra directories, as a canonical, deduplicated allow-list. Both
|
||||
* the workspace and every extra root must be granted together by every
|
||||
* backend; this is the one derivation of that set, kept separate from the
|
||||
* platform temp policy each backend documents for itself (Seatbelt and the fs
|
||||
* the workspace and every extra root are included by the filesystem fence and
|
||||
* POSIX process backends; Windows process confinement ignores extra roots.
|
||||
* This derivation is separate from each backend's platform temp policy (Seatbelt and the fs
|
||||
* fence use {@link writableRoots}; bwrap and Landlock add their own temp
|
||||
* grants).
|
||||
* @param policy - the file-effect policy to derive the allow-list from.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create session-root.txt in the current directory containing exactly: session root. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"<agent-name>\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"<agent-name>\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"<agent-name>\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Additional writable directories: [\"{{cwd}}/extra\"]. On Windows, these additional grants apply to file tools only, not sandboxed processes. Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"<agent-name>\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[11],"source":{"kind":"fallback"}}}
|
||||
|
||||
Reference in new issue
Block a user