@deepseek-ai/dsh-sandbox-windows-acl 0.1.6-alpha.2 → 0.1.7-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +3 -3
- package/README.md +19 -15
- package/README.zh.md +17 -13
- package/lib/index.js +1 -1
- package/lib/runner.js +1 -1
- package/lib/types/acl.d.ts +55 -19
- package/lib/types/ffi.d.ts +3 -1
- package/lib/types/grant.d.ts +25 -18
- package/lib/types/index.d.ts +4 -1
- package/lib/types/token.d.ts +10 -0
- package/lib/types/win32-abi.d.ts +33 -0
- package/lib/{types-CutH1Lgc.js → types-DxezulnA.js} +267 -78
- package/package.json +10 -10
package/README.i18n.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
|
-
# pnpm run verify-translation-pairing --write packages
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-windows-acl//README.md
|
|
5
|
+
README.md: 42b2290028c9138e38594ae23ce94fb3f0ebaa85
|
|
6
|
+
README.zh.md: 6a99eec14adf65027f199079f7ae08d94b0636ad
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
|
|
9
9
|
|
|
10
10
|
## Summary
|
|
11
11
|
|
|
12
|
-
On Windows, this package confines child-process writes to the workspace and a private temporary directory
|
|
12
|
+
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.
|
|
13
13
|
|
|
14
14
|
## Table of Contents
|
|
15
15
|
|
|
@@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
|
|
|
59
59
|
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
|
60
60
|
const { stdout, stderr, exitCode } = await child.wait()
|
|
61
61
|
|
|
62
|
-
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace
|
|
62
|
+
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
|
|
63
63
|
rmSync(tempDir, { recursive: true, force: true })
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
The workspace
|
|
66
|
+
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.
|
|
67
67
|
|
|
68
68
|
### What confinement gives you
|
|
69
69
|
|
|
70
|
-
Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes are denied except the documented
|
|
70
|
+
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.
|
|
71
71
|
|
|
72
72
|
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.
|
|
73
73
|
|
|
@@ -87,11 +87,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
|
|
|
87
87
|
|
|
88
88
|
### Mechanism
|
|
89
89
|
|
|
90
|
-
The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. 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. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root
|
|
90
|
+
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.
|
|
91
91
|
|
|
92
92
|
### Modes and token lists
|
|
93
93
|
|
|
94
|
-
`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. 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
|
|
94
|
+
`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.
|
|
95
95
|
|
|
96
96
|
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.
|
|
97
97
|
|
|
@@ -111,15 +111,18 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu
|
|
|
111
111
|
|
|
112
112
|
### Verified boundaries
|
|
113
113
|
|
|
114
|
-
- **Everyone
|
|
115
|
-
- **
|
|
116
|
-
- **
|
|
114
|
+
- **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.
|
|
115
|
+
- **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.
|
|
116
|
+
- **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.
|
|
117
|
+
- **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.
|
|
118
|
+
- **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.
|
|
117
119
|
- **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.
|
|
118
|
-
- **
|
|
119
|
-
- **
|
|
120
|
+
- **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.
|
|
121
|
+
- **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.
|
|
122
|
+
- **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.
|
|
120
123
|
- **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.
|
|
121
124
|
- **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.
|
|
122
|
-
- **`whoami` and token-inspection cmdlets fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child,
|
|
125
|
+
- **`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.
|
|
123
126
|
|
|
124
127
|
### Header verification and source map
|
|
125
128
|
|
|
@@ -127,10 +130,10 @@ The sandbox-owned SID, ACL, token, file, and lock declarations are checked again
|
|
|
127
130
|
|
|
128
131
|
| File | Role |
|
|
129
132
|
|---|---|
|
|
130
|
-
| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL grants, fail-closed spawn and dispose |
|
|
133
|
+
| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL and label grants, fail-closed spawn and dispose |
|
|
131
134
|
| [`src/runner.ts`](src/runner.ts) | The runner entry over shared Win32 process primitives |
|
|
132
135
|
| [`src/grant.ts`](src/grant.ts) | `AclWriteGrant`: server-side grant materialization and revocation |
|
|
133
|
-
| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token and
|
|
136
|
+
| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token, DACL, and mandatory-label primitives behind the sandbox |
|
|
134
137
|
|
|
135
138
|
</details>
|
|
136
139
|
|
|
@@ -173,7 +176,8 @@ These limits define when the backend is a poor fit or needs special operational
|
|
|
173
176
|
- **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.
|
|
174
177
|
- **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.
|
|
175
178
|
- **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.
|
|
176
|
-
- **
|
|
179
|
+
- **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.
|
|
180
|
+
- **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.
|
|
177
181
|
- **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary.
|
|
178
182
|
|
|
179
183
|
<a id="dev-note"></a>
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@ kind: "package-library"
|
|
|
9
9
|
|
|
10
10
|
## 概述
|
|
11
11
|
|
|
12
|
-
在 Windows
|
|
12
|
+
在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
|
|
13
13
|
|
|
14
14
|
## 目录
|
|
15
15
|
|
|
@@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
|
|
|
59
59
|
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
|
60
60
|
const { stdout, stderr, exitCode } = await child.wait()
|
|
61
61
|
|
|
62
|
-
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace
|
|
62
|
+
sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
|
|
63
63
|
rmSync(tempDir, { recursive: true, force: true })
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。
|
|
67
67
|
|
|
68
68
|
### 隔离给你带来什么
|
|
69
69
|
|
|
70
|
-
在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL
|
|
70
|
+
在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
|
|
71
71
|
|
|
72
72
|
临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。
|
|
73
73
|
|
|
@@ -87,11 +87,11 @@ rmSync(tempDir, { recursive: true, force: true })
|
|
|
87
87
|
|
|
88
88
|
### 机制
|
|
89
89
|
|
|
90
|
-
调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs
|
|
90
|
+
调用者令牌被复制为 `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。
|
|
91
91
|
|
|
92
92
|
### 模式与令牌列表
|
|
93
93
|
|
|
94
|
-
`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID
|
|
94
|
+
`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 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
|
|
95
95
|
|
|
96
96
|
NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。
|
|
97
97
|
|
|
@@ -111,15 +111,18 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
|
|
|
111
111
|
|
|
112
112
|
### 已验证边界
|
|
113
113
|
|
|
114
|
-
- **Everyone
|
|
115
|
-
-
|
|
116
|
-
-
|
|
114
|
+
- **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。
|
|
115
|
+
- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。
|
|
116
|
+
- **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。
|
|
117
|
+
- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。
|
|
118
|
+
- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。
|
|
117
119
|
- **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
+
- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。
|
|
121
|
+
- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。
|
|
122
|
+
- **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。
|
|
120
123
|
- **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。
|
|
121
124
|
- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。
|
|
122
|
-
- **受限令牌下 `whoami` 与令牌检查 cmdlet
|
|
125
|
+
- **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。
|
|
123
126
|
|
|
124
127
|
### 头文件验证与源码索引
|
|
125
128
|
|
|
@@ -173,7 +176,8 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服
|
|
|
173
176
|
- **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。
|
|
174
177
|
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。
|
|
175
178
|
- **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
|
176
|
-
-
|
|
179
|
+
- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。
|
|
180
|
+
- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。
|
|
177
181
|
- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。
|
|
178
182
|
|
|
179
183
|
<a id="dev-note"></a>
|
package/lib/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as assertTempRootOutsideWorkspace, i as AclWriteGrant, n as tempWriteSid, r as workspaceWriteSid, t as AclSandbox } from "./types-
|
|
1
|
+
import { a as assertTempRootOutsideWorkspace, i as AclWriteGrant, n as tempWriteSid, r as workspaceWriteSid, t as AclSandbox } from "./types-DxezulnA.js";
|
|
2
2
|
export { AclSandbox, AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid };
|
package/lib/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as assertTempRootOutsideWorkspace, n as tempWriteSid, o as win32, r as workspaceWriteSid, t as AclSandbox } from "./types-
|
|
1
|
+
import { a as assertTempRootOutsideWorkspace, n as tempWriteSid, o as win32, r as workspaceWriteSid, t as AclSandbox } from "./types-DxezulnA.js";
|
|
2
2
|
import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
|
package/lib/types/acl.d.ts
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* failure is reported with the API name, the exact Win32 code, the formatted
|
|
6
6
|
* system text, and the affected path.
|
|
7
7
|
*
|
|
8
|
+
* Each grant applies three edits in ONE SetNamedSecurityInfoW call: the
|
|
9
|
+
* capability-SID allow ACE, a Deny ACE that removes the ambient
|
|
10
|
+
* `FILE_DELETE_CHILD` right from the world SID, and a Low no-write-up
|
|
11
|
+
* mandatory label ({@link buildLowLabelAcl}). The deny is what keeps one
|
|
12
|
+
* granted root out of another's reach: Windows also authorizes a delete from
|
|
13
|
+
* the parent directory's `FILE_DELETE_CHILD` right, which the token's
|
|
14
|
+
* write-restricted intersection does not reach, and every granted root carries
|
|
15
|
+
* the Low label that clears the integrity check.
|
|
16
|
+
*
|
|
8
17
|
* Concurrency: grants are read-merge-write against the directory's CURRENT
|
|
9
18
|
* DACL, and the whole get-merge-set sequence runs under a per-path exclusive
|
|
10
19
|
* LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
|
|
@@ -17,13 +26,17 @@ import type { NativePtr, Win32Bindings } from './ffi.ts';
|
|
|
17
26
|
* perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
|
|
18
27
|
* MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
|
|
19
28
|
* `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
|
|
20
|
-
* removes every ACE for the trustee.
|
|
29
|
+
* removes every ACE for the trustee. `inheritance` defaults to children of
|
|
30
|
+
* both kinds; the ambient-delete deny narrows it to containers because
|
|
31
|
+
* FILE_DELETE_CHILD is meaningless on a file and its bit would otherwise
|
|
32
|
+
* spread through the file's inherited mask.
|
|
21
33
|
* @param sidPtr - the trustee SID the entry names.
|
|
22
|
-
* @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS).
|
|
23
|
-
* @param permissions - the access mask to grant (0 for REVOKE_ACCESS).
|
|
34
|
+
* @param mode - the access mode (GRANT_ACCESS, DENY_ACCESS, or REVOKE_ACCESS).
|
|
35
|
+
* @param permissions - the access mask to grant or deny (0 for REVOKE_ACCESS).
|
|
36
|
+
* @param inheritance - the ACE inheritance flags.
|
|
24
37
|
* @returns the packed entry buffer.
|
|
25
38
|
*/
|
|
26
|
-
export declare function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer;
|
|
39
|
+
export declare function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number, inheritance?: number): Buffer;
|
|
27
40
|
/**
|
|
28
41
|
* One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16
|
|
29
42
|
* hex of sha256(lowercased path)>.lock`. The lock root derives from
|
|
@@ -50,30 +63,53 @@ export declare function lockFilePath(api: Win32Bindings, path: string): string;
|
|
|
50
63
|
* @returns the action's result.
|
|
51
64
|
*/
|
|
52
65
|
export declare function withPathLock<T>(api: Win32Bindings, path: string, action: () => T): T;
|
|
66
|
+
/**
|
|
67
|
+
* Build the Low mandatory label applied with every write grant: one
|
|
68
|
+
* SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up
|
|
69
|
+
* policy, inheriting to subcontainers and objects so later children carry the
|
|
70
|
+
* same label. The caller frees the returned ACL with LocalFree
|
|
71
|
+
* (SetNamedSecurityInfoW copies it); every Win32 call is checked and a
|
|
72
|
+
* half-built ACL is released before the error is thrown.
|
|
73
|
+
* @param api - the binding table.
|
|
74
|
+
* @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names.
|
|
75
|
+
* @returns the ACL carrying the single inheritable label ACE.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): NativePtr;
|
|
53
78
|
/**
|
|
54
79
|
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID
|
|
55
|
-
* on `path`,
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
80
|
+
* on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and
|
|
81
|
+
* apply the Low mandatory label — one merge. The deny inherits to containers
|
|
82
|
+
* only: the right is evaluated on directories, and inheriting its bit onto
|
|
83
|
+
* files would deny every `FILE_ALL_ACCESS`/`GENERIC_ALL` open inside the root
|
|
84
|
+
* (0x40 is a member of that mask). The capability ACE's DELETE bit is then the
|
|
85
|
+
* only delete authority inside the root, so a file whose own DACL grants no
|
|
86
|
+
* DELETE is no longer deletable through its parent's rights.
|
|
87
|
+
*
|
|
88
|
+
* Idempotent: the exact ACE, deny, and label together SKIP the
|
|
89
|
+
* SetNamedSecurityInfoW apply, which would otherwise re-propagate the
|
|
90
|
+
* identical descriptor across the whole tree (eager inheritance; minutes on
|
|
91
|
+
* large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs
|
|
92
|
+
* survive (same shape as {@link revokeWrite}). Runs under the per-path lock.
|
|
93
|
+
* The directory must be owned by the caller AND grant WRITE_OWNER (the label
|
|
94
|
+
* lives in the SACL; owner-implicit rights cover only READ_CONTROL and
|
|
95
|
+
* WRITE_DAC) — a Full-control workspace satisfies both.
|
|
65
96
|
* @param api - the binding table.
|
|
66
|
-
* @param path - the directory whose DACL
|
|
97
|
+
* @param path - the directory whose DACL and label gain the grant (the workspace or temp root).
|
|
67
98
|
* @param sidPtr - the capability SID the ACE names.
|
|
99
|
+
* @param lowLabelSidPtr - the Low integrity SID the mandatory label names.
|
|
100
|
+
* @param worldSidPtr - the Everyone SID the ambient-delete deny names.
|
|
68
101
|
*/
|
|
69
|
-
export declare function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void;
|
|
102
|
+
export declare function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, lowLabelSidPtr: NativePtr, worldSidPtr: NativePtr): void;
|
|
70
103
|
/**
|
|
71
104
|
* Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS
|
|
72
|
-
* merge — other entries are preserved).
|
|
73
|
-
*
|
|
105
|
+
* merge — other entries are preserved). The shared Low label is cleared only
|
|
106
|
+
* when no other capability grant remains on the directory: two grants may
|
|
107
|
+
* target one directory, and the surviving one still needs the label for its
|
|
108
|
+
* child's writes. Returns whether an ACE removal was attempted (false when the
|
|
109
|
+
* directory carries no DACL at all).
|
|
74
110
|
*
|
|
75
111
|
* Runs under the per-path lock (the whole get-merge-set sequence); the
|
|
76
|
-
* descriptor/ACL allocation contract lives on {@link
|
|
112
|
+
* descriptor/ACL allocation contract lives on {@link readCurrentSecurity}.
|
|
77
113
|
* @param api - the binding table.
|
|
78
114
|
* @param path - the directory whose DACL loses the capability-SID ACEs.
|
|
79
115
|
* @param sidPtr - the capability SID whose ACEs are removed.
|
package/lib/types/ffi.d.ts
CHANGED
|
@@ -17,7 +17,9 @@ export interface Win32Bindings extends Win32ProcessBindings {
|
|
|
17
17
|
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number;
|
|
18
18
|
createRestrictedToken(existing: NativePtr, flags: number, disableCount: number, disableSids: null, deletePrivilegeCount: number, privilegesToDelete: null, restrictCount: number, restrictingSids: Buffer, newToken: NativePtr): number;
|
|
19
19
|
setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number;
|
|
20
|
-
|
|
20
|
+
initializeAcl(acl: NativePtr, length: number, revision: number): number;
|
|
21
|
+
addMandatoryAce(acl: NativePtr, revision: number, aceFlags: number, policy: number, sid: NativePtr): number;
|
|
22
|
+
setNamedSecurityInfoW(path: string, objectType: number, information: number, owner: null, group: null, dacl: NativePtr | null, sacl: NativePtr | null): number;
|
|
21
23
|
getNamedSecurityInfoW(path: string, objectType: number, information: number, owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr): number;
|
|
22
24
|
getTempPathW(length: number, buffer: Buffer): number;
|
|
23
25
|
setEnvironmentVariableW(name: string, value: string): number;
|
package/lib/types/grant.d.ts
CHANGED
|
@@ -7,54 +7,61 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
|
9
9
|
* instance (revoking every path granted so far); `dispose` revokes every
|
|
10
|
-
*
|
|
10
|
+
* revocable grant, leaves the standing workspace edits in place, and reports
|
|
11
|
+
* every cleanup failure.
|
|
11
12
|
* @module @deepseek-ai/dsh-sandbox-windows-acl/grant
|
|
12
13
|
*/
|
|
13
14
|
import type { Win32Bindings } from './ffi.ts';
|
|
14
15
|
/**
|
|
15
16
|
* One write SID's provider-lifetime grant materialization: the parsed SID
|
|
16
|
-
* pointer plus every directory whose DACL currently carries its ACE
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* pointer plus every directory whose DACL currently carries its ACE and whose
|
|
18
|
+
* label ACL carries the Low mandatory label. Workspace paths are added
|
|
19
|
+
* STANDING (their security descriptor edits are the cross-session reuse cache
|
|
20
|
+
* and outlive the grant — dispose() skips revoking them, or the next
|
|
19
21
|
* provision would re-propagate the whole tree); temp paths are revocable
|
|
20
22
|
* (dispose() revokes them — an inheritable ACE must not outlive its
|
|
21
23
|
* session's temp directory). Create with {@link AclWriteGrant.create};
|
|
22
|
-
* dispose revokes the revocable paths and frees
|
|
24
|
+
* dispose revokes the revocable paths and frees every SID.
|
|
23
25
|
*/
|
|
24
26
|
export declare class AclWriteGrant {
|
|
25
27
|
/** The write SID in SDDL string form. */
|
|
26
28
|
readonly writeSid: string;
|
|
27
29
|
private readonly api;
|
|
28
30
|
private readonly sidPtr;
|
|
31
|
+
private readonly lowLabelSidPtr;
|
|
32
|
+
private readonly worldSidPtr;
|
|
29
33
|
private readonly revocablePaths;
|
|
30
34
|
private readonly standingPaths;
|
|
31
35
|
private constructor();
|
|
32
36
|
/**
|
|
33
|
-
* Parse the SID string
|
|
34
|
-
*
|
|
37
|
+
* Parse the SID string, create the Low integrity SID the grants label with
|
|
38
|
+
* and the world SID their ambient-delete deny names, and open the binding
|
|
39
|
+
* table (lazily, once per server). Fail-closed: any failure throws — nothing
|
|
40
|
+
* is granted yet.
|
|
35
41
|
* @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
|
|
36
42
|
* @param api - optional already-resolved bindings (tests).
|
|
37
43
|
* @returns the ready grant (no ACEs yet).
|
|
38
44
|
*/
|
|
39
45
|
static create(writeSid: string, api?: Win32Bindings): AclWriteGrant;
|
|
40
46
|
/**
|
|
41
|
-
* Grant the write ACE
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* revoke it, and revoking an
|
|
47
|
-
* treat a throw as a failed
|
|
48
|
-
* revoke the paths granted so
|
|
49
|
-
*
|
|
50
|
-
* @param
|
|
47
|
+
* Grant the write ACE, the ambient-delete deny, and the Low mandatory label
|
|
48
|
+
* on one directory (idempotent: an already-standing exact ACE, deny, and
|
|
49
|
+
* label skip the eager full-tree re-propagation — see {@link grantWrite})
|
|
50
|
+
* and record the path for {@link dispose} unless it is standing. The path is
|
|
51
|
+
* recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
|
|
52
|
+
* SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
|
|
53
|
+
* ungranted path is a no-op merge. Callers treat a throw as a failed
|
|
54
|
+
* materialization and dispose the instance to revoke the paths granted so
|
|
55
|
+
* far.
|
|
56
|
+
* @param path - the directory whose DACL and label gain the grant.
|
|
57
|
+
* @param standing - the edits outlive this grant (the workspace reuse
|
|
51
58
|
* cache; dispose() skips revoking it). Default false (revoked on
|
|
52
59
|
* dispose — the temp-directory lifecycle).
|
|
53
60
|
*/
|
|
54
61
|
add(path: string, standing?: boolean): void;
|
|
55
62
|
/** Every directory currently carrying the grant, in grant order. */
|
|
56
63
|
get paths(): readonly string[];
|
|
57
|
-
/** Revoke every revocable grant (standing
|
|
64
|
+
/** Revoke every revocable grant (standing security descriptor edits stay) and free the SIDs; reports every cleanup failure. */
|
|
58
65
|
dispose(): void;
|
|
59
66
|
}
|
|
60
67
|
//# sourceMappingURL=grant.d.ts.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
|
|
11
11
|
* and LOCAL are absent from both lists — see the seam's dual-list contract
|
|
12
12
|
* in `packages/sandbox/sandbox-local` and the package README's Modes section
|
|
13
|
-
* for the complete boundary). The
|
|
13
|
+
* for the complete boundary). The intersection covers only the object's own
|
|
14
|
+
* access check, so the token is also lowered to Low integrity and every
|
|
15
|
+
* granted directory carries a Low no-write-up label and the ambient-delete
|
|
16
|
+
* deny the `acl` module documents. The write SID is the per-WORKSPACE identity
|
|
14
17
|
* ({@link workspaceWriteSid}): deterministic from the canonical workspace
|
|
15
18
|
* path, so the workspace-root ACE materializes once per workspace per
|
|
16
19
|
* machine and every later provision hits the exact-ACE skip — the
|
package/lib/types/token.d.ts
CHANGED
|
@@ -49,6 +49,16 @@ export declare function makeWellKnownSid(api: Win32Bindings, type: number): Nati
|
|
|
49
49
|
* @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
|
|
50
50
|
*/
|
|
51
51
|
export declare function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void;
|
|
52
|
+
/**
|
|
53
|
+
* Lower the restricted token's integrity level to Low (S-1-16-4096), the level
|
|
54
|
+
* the mandatory labels `grantWrite` applies are matched against; a token left
|
|
55
|
+
* at Medium would ignore them. Requires TOKEN_ADJUST_DEFAULT on the token;
|
|
56
|
+
* fails closed before any child is spawned.
|
|
57
|
+
* @param api - the binding table.
|
|
58
|
+
* @param token - the restricted token to lower.
|
|
59
|
+
* @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096).
|
|
60
|
+
*/
|
|
61
|
+
export declare function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void;
|
|
52
62
|
/** The well-known SID packed into every restricted token's restricting list. */
|
|
53
63
|
export interface RestrictingSidSet {
|
|
54
64
|
world: NativePtr;
|
package/lib/types/win32-abi.d.ts
CHANGED
|
@@ -41,6 +41,33 @@ export declare const TokenGroups = 2;
|
|
|
41
41
|
export declare const TokenDefaultDacl = 6;
|
|
42
42
|
/** SECURITY_INFORMATION flag selecting the DACL. */
|
|
43
43
|
export declare const DACL_SECURITY_INFORMATION = 4;
|
|
44
|
+
/** SECURITY_INFORMATION flag selecting the mandatory integrity label. */
|
|
45
|
+
export declare const LABEL_SECURITY_INFORMATION = 16;
|
|
46
|
+
/** ACE type carrying a mandatory integrity label. */
|
|
47
|
+
export declare const SYSTEM_MANDATORY_LABEL_ACE_TYPE = 17;
|
|
48
|
+
/**
|
|
49
|
+
* Mandatory policy denying write-class access to higher-integrity objects.
|
|
50
|
+
* The kernel applies it inside the access check, so it also covers writes and
|
|
51
|
+
* deletes granted through a parent directory's FILE_DELETE_CHILD right —
|
|
52
|
+
* the path the write-restricted pass-2 intersection does not reach.
|
|
53
|
+
*/
|
|
54
|
+
export declare const SYSTEM_MANDATORY_LABEL_NO_WRITE_UP = 1;
|
|
55
|
+
/** TOKEN_INFORMATION_CLASS value for the token's integrity level. */
|
|
56
|
+
export declare const TokenIntegrityLevel = 25;
|
|
57
|
+
/** Group attribute marking the integrity SID of a TOKEN_MANDATORY_LABEL. */
|
|
58
|
+
export declare const SE_GROUP_INTEGRITY = 32;
|
|
59
|
+
/** WELL_KNOWN_SID_TYPE value for the Low mandatory level (S-1-16-4096). */
|
|
60
|
+
export declare const WinLowLabelSid = 66;
|
|
61
|
+
/** x64 TOKEN_MANDATORY_LABEL byte size (the SID is referenced, not embedded). */
|
|
62
|
+
export declare const TOKEN_MANDATORY_LABEL_SIZE = 16;
|
|
63
|
+
/** x64 ACL header byte size (AclRevision, Sbz1, AclSize, AceCount, Sbz2). */
|
|
64
|
+
export declare const ACL_HEADER_SIZE = 8;
|
|
65
|
+
/** Bytes a SYSTEM_MANDATORY_LABEL_ACE occupies beyond the ACL header and its SID. */
|
|
66
|
+
export declare const MANDATORY_ACE_OVERHEAD = 8;
|
|
67
|
+
/** ACL revision accepted by InitializeAcl and AddMandatoryAce. */
|
|
68
|
+
export declare const ACL_REVISION = 2;
|
|
69
|
+
/** LocalAlloc flag selecting zero-initialized fixed memory (LMEM_FIXED | LMEM_ZEROINIT). */
|
|
70
|
+
export declare const LPTR = 64;
|
|
44
71
|
/** SE_OBJECT_TYPE value for filesystem objects. */
|
|
45
72
|
export declare const SE_FILE_OBJECT = 1;
|
|
46
73
|
/** TRUSTEE_TYPE value used when trustee classification is unknown. */
|
|
@@ -51,10 +78,14 @@ export declare const TRUSTEE_IS_SID = 0;
|
|
|
51
78
|
export declare const NO_MULTIPLE_TRUSTEE = 0;
|
|
52
79
|
/** EXPLICIT_ACCESS mode that grants access. */
|
|
53
80
|
export declare const GRANT_ACCESS = 1;
|
|
81
|
+
/** EXPLICIT_ACCESS mode that denies access. */
|
|
82
|
+
export declare const DENY_ACCESS = 3;
|
|
54
83
|
/** EXPLICIT_ACCESS mode that revokes access. */
|
|
55
84
|
export declare const REVOKE_ACCESS = 4;
|
|
56
85
|
/** ACE inheritance flags for child containers and objects. */
|
|
57
86
|
export declare const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 3;
|
|
87
|
+
/** ACE inheritance flag for child containers only (directories; files do not inherit). */
|
|
88
|
+
export declare const CONTAINER_INHERIT_ACE = 2;
|
|
58
89
|
/** Legacy Win32 maximum path character count used by GetTempPathW. */
|
|
59
90
|
export declare const MAX_PATH = 260;
|
|
60
91
|
/** Successful Win32 status code. */
|
|
@@ -79,6 +110,8 @@ export declare const LOCKFILE_EXCLUSIVE_LOCK = 2;
|
|
|
79
110
|
export declare const LOCKFILE_FAIL_IMMEDIATELY = 1;
|
|
80
111
|
/** ACE type for an allowed-access entry. */
|
|
81
112
|
export declare const ACCESS_ALLOWED_ACE_TYPE = 0;
|
|
113
|
+
/** ACE type for a denied-access entry (shares the allowed ACE's Mask/SID layout). */
|
|
114
|
+
export declare const ACCESS_DENIED_ACE_TYPE = 1;
|
|
82
115
|
/** Maximum SID sub-authority count. */
|
|
83
116
|
export declare const SID_MAX_SUB_AUTHORITIES = 15;
|
|
84
117
|
/** ACE flag marking inherited entries. */
|
|
@@ -188,6 +188,18 @@ function bindings() {
|
|
|
188
188
|
PVOID,
|
|
189
189
|
PPVOID
|
|
190
190
|
]),
|
|
191
|
+
initializeAcl: bind(advapi32, "InitializeAcl", "int", [
|
|
192
|
+
PVOID,
|
|
193
|
+
"uint32",
|
|
194
|
+
"uint32"
|
|
195
|
+
]),
|
|
196
|
+
addMandatoryAce: bind(advapi32, "AddMandatoryAce", "int", [
|
|
197
|
+
PVOID,
|
|
198
|
+
"uint32",
|
|
199
|
+
"uint32",
|
|
200
|
+
"uint32",
|
|
201
|
+
PVOID
|
|
202
|
+
]),
|
|
191
203
|
setNamedSecurityInfoW: bind(advapi32, "SetNamedSecurityInfoW", "uint32", [
|
|
192
204
|
"str16",
|
|
193
205
|
"int",
|
|
@@ -272,6 +284,15 @@ function getTempPath(api) {
|
|
|
272
284
|
* failure is reported with the API name, the exact Win32 code, the formatted
|
|
273
285
|
* system text, and the affected path.
|
|
274
286
|
*
|
|
287
|
+
* Each grant applies three edits in ONE SetNamedSecurityInfoW call: the
|
|
288
|
+
* capability-SID allow ACE, a Deny ACE that removes the ambient
|
|
289
|
+
* `FILE_DELETE_CHILD` right from the world SID, and a Low no-write-up
|
|
290
|
+
* mandatory label ({@link buildLowLabelAcl}). The deny is what keeps one
|
|
291
|
+
* granted root out of another's reach: Windows also authorizes a delete from
|
|
292
|
+
* the parent directory's `FILE_DELETE_CHILD` right, which the token's
|
|
293
|
+
* write-restricted intersection does not reach, and every granted root carries
|
|
294
|
+
* the Low label that clears the integrity check.
|
|
295
|
+
*
|
|
275
296
|
* Concurrency: grants are read-merge-write against the directory's CURRENT
|
|
276
297
|
* DACL, and the whole get-merge-set sequence runs under a per-path exclusive
|
|
277
298
|
* LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
|
|
@@ -283,17 +304,21 @@ function getTempPath(api) {
|
|
|
283
304
|
* perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
|
|
284
305
|
* MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
|
|
285
306
|
* `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
|
|
286
|
-
* removes every ACE for the trustee.
|
|
307
|
+
* removes every ACE for the trustee. `inheritance` defaults to children of
|
|
308
|
+
* both kinds; the ambient-delete deny narrows it to containers because
|
|
309
|
+
* FILE_DELETE_CHILD is meaningless on a file and its bit would otherwise
|
|
310
|
+
* spread through the file's inherited mask.
|
|
287
311
|
* @param sidPtr - the trustee SID the entry names.
|
|
288
|
-
* @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS).
|
|
289
|
-
* @param permissions - the access mask to grant (0 for REVOKE_ACCESS).
|
|
312
|
+
* @param mode - the access mode (GRANT_ACCESS, DENY_ACCESS, or REVOKE_ACCESS).
|
|
313
|
+
* @param permissions - the access mask to grant or deny (0 for REVOKE_ACCESS).
|
|
314
|
+
* @param inheritance - the ACE inheritance flags.
|
|
290
315
|
* @returns the packed entry buffer.
|
|
291
316
|
*/
|
|
292
|
-
function buildExplicitAccess(sidPtr, mode, permissions) {
|
|
317
|
+
function buildExplicitAccess(sidPtr, mode, permissions, inheritance = 3) {
|
|
293
318
|
const entry = Buffer.alloc(48);
|
|
294
319
|
entry.writeUInt32LE(permissions, 0);
|
|
295
320
|
entry.writeUInt32LE(mode, 4);
|
|
296
|
-
entry.writeUInt32LE(
|
|
321
|
+
entry.writeUInt32LE(inheritance, 8);
|
|
297
322
|
entry.writeUInt32LE(0, 24);
|
|
298
323
|
entry.writeUInt32LE(0, 28);
|
|
299
324
|
entry.writeUInt32LE(0, 32);
|
|
@@ -356,76 +381,173 @@ function withPathLock(api, path, action) {
|
|
|
356
381
|
return result;
|
|
357
382
|
}
|
|
358
383
|
/**
|
|
359
|
-
* Read the directory's current explicit DACL via
|
|
384
|
+
* Read the directory's current explicit DACL and mandatory label via
|
|
385
|
+
* GetNamedSecurityInfoW.
|
|
360
386
|
* Allocation contract (the POC's RevokeAccess, minus its missing checks): the
|
|
361
387
|
* returned ACL pointer sits INSIDE the security descriptor allocation — only
|
|
362
388
|
* the descriptor may be LocalFree'd, and it must not be freed before
|
|
363
389
|
* SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself
|
|
364
390
|
* corrupts the heap (verified the hard way).
|
|
365
391
|
* @param api - the binding table.
|
|
366
|
-
* @param path - the directory whose DACL
|
|
367
|
-
* @returns the current explicit DACL (null when the directory carries none)
|
|
392
|
+
* @param path - the directory whose DACL and label are read.
|
|
393
|
+
* @returns the current explicit DACL and label ACL (null when the directory carries none) plus their owning descriptor.
|
|
368
394
|
*/
|
|
369
|
-
function
|
|
395
|
+
function readCurrentSecurity(api, path) {
|
|
370
396
|
const ownerSlot = allocPtrSlot();
|
|
371
397
|
const groupSlot = allocPtrSlot();
|
|
372
398
|
const daclSlot = allocPtrSlot();
|
|
373
399
|
const saclSlot = allocPtrSlot();
|
|
374
400
|
const descriptorSlot = allocPtrSlot();
|
|
375
|
-
const readResult = api.getNamedSecurityInfoW(path, 1,
|
|
401
|
+
const readResult = api.getNamedSecurityInfoW(path, 1, 20, ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot);
|
|
376
402
|
if (readResult !== 0) throwWin32(api, "GetNamedSecurityInfoW", readResult, path);
|
|
377
403
|
return {
|
|
378
404
|
oldAcl: decodePtr(daclSlot),
|
|
405
|
+
labelAcl: decodePtr(saclSlot),
|
|
379
406
|
descriptor: decodePtr(descriptorSlot)
|
|
380
407
|
};
|
|
381
408
|
}
|
|
382
409
|
/**
|
|
383
|
-
*
|
|
410
|
+
* Build the Low mandatory label applied with every write grant: one
|
|
411
|
+
* SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up
|
|
412
|
+
* policy, inheriting to subcontainers and objects so later children carry the
|
|
413
|
+
* same label. The caller frees the returned ACL with LocalFree
|
|
414
|
+
* (SetNamedSecurityInfoW copies it); every Win32 call is checked and a
|
|
415
|
+
* half-built ACL is released before the error is thrown.
|
|
416
|
+
* @param api - the binding table.
|
|
417
|
+
* @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names.
|
|
418
|
+
* @returns the ACL carrying the single inheritable label ACE.
|
|
419
|
+
*/
|
|
420
|
+
function buildLowLabelAcl(api, lowLabelSidPtr) {
|
|
421
|
+
const sidLength = api.getLengthSid(lowLabelSidPtr);
|
|
422
|
+
if (sidLength === 0) throwLastError$1(api, "GetLengthSid", "Low mandatory label SID");
|
|
423
|
+
const aclLength = 16 + sidLength;
|
|
424
|
+
const acl = api.localAlloc(64, aclLength);
|
|
425
|
+
if (isNullPtr$1(acl)) throwLastError$1(api, "LocalAlloc", "Low mandatory label ACL");
|
|
426
|
+
if (api.initializeAcl(acl, aclLength, 2) === 0) {
|
|
427
|
+
const win32Code = api.getLastError();
|
|
428
|
+
api.localFree(acl);
|
|
429
|
+
throwWin32(api, "InitializeAcl", win32Code, "Low mandatory label ACL");
|
|
430
|
+
}
|
|
431
|
+
if (api.addMandatoryAce(acl, 2, 3, 1, lowLabelSidPtr) === 0) {
|
|
432
|
+
const win32Code = api.getLastError();
|
|
433
|
+
api.localFree(acl);
|
|
434
|
+
throwWin32(api, "AddMandatoryAce", win32Code, "Low mandatory label ACL");
|
|
435
|
+
}
|
|
436
|
+
return acl;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* True when the label ACL already carries the EXACT label this module would
|
|
440
|
+
* add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low
|
|
441
|
+
* SID), so a re-grant can skip the eager full-tree propagation.
|
|
442
|
+
* @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}).
|
|
443
|
+
* @param lowLabelSidPtr - the Low integrity SID to match.
|
|
444
|
+
* @returns whether the exact label ACE is already present.
|
|
445
|
+
*/
|
|
446
|
+
function hasExactLabel(labelAcl, lowLabelSidPtr) {
|
|
447
|
+
return hasExactEntry(labelAcl, 17, 3, 1, lowLabelSidPtr);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl`
|
|
384
451
|
* (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch),
|
|
385
|
-
* free the descriptor before applying the merged ACL, apply
|
|
386
|
-
*
|
|
452
|
+
* free the descriptor before applying the merged ACL, apply the merged DACL
|
|
453
|
+
* together with the label edit in one SetNamedSecurityInfoW call, then free
|
|
454
|
+
* every ACL this call owns — checking each call and reporting with the
|
|
455
|
+
* caller's label. The entry count derives from the buffer, so a grant can
|
|
456
|
+
* carry its capability ACE and its ambient-delete deny in one merge.
|
|
387
457
|
* @param api - the binding table.
|
|
388
|
-
* @param path - the directory the DACL
|
|
389
|
-
* @param
|
|
390
|
-
* @param oldAcl - the current explicit DACL (from {@link
|
|
458
|
+
* @param path - the directory the DACL and label edits apply to.
|
|
459
|
+
* @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke).
|
|
460
|
+
* @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}).
|
|
461
|
+
* @param labelEdit - the label change to apply alongside the DACL.
|
|
391
462
|
* @param descriptor - the descriptor allocation owning `oldAcl`.
|
|
392
463
|
* @param label - the caller's name for error details.
|
|
393
464
|
*/
|
|
394
|
-
function mergeAndApply(api, path,
|
|
465
|
+
function mergeAndApply(api, path, entries, oldAcl, labelEdit, descriptor, label) {
|
|
395
466
|
const newAclSlot = allocPtrSlot();
|
|
396
|
-
const mergeResult = api.setEntriesInAclW(
|
|
467
|
+
const mergeResult = api.setEntriesInAclW(entries.length / 48, entries, oldAcl, newAclSlot);
|
|
397
468
|
if (mergeResult !== 0) {
|
|
398
469
|
if (descriptor !== null) api.localFree(descriptor);
|
|
470
|
+
if (labelEdit.kind === "apply") api.localFree(labelEdit.acl);
|
|
399
471
|
throwWin32(api, "SetEntriesInAclW", mergeResult, `${label}(${path})`);
|
|
400
472
|
}
|
|
401
473
|
const newAcl = decodePtr(newAclSlot);
|
|
402
474
|
if (newAcl === null) {
|
|
403
475
|
if (descriptor !== null) api.localFree(descriptor);
|
|
476
|
+
if (labelEdit.kind === "apply") api.localFree(labelEdit.acl);
|
|
404
477
|
throwWin32(api, "SetEntriesInAclW", api.getLastError(), `${label}(${path}): null new ACL`);
|
|
405
478
|
}
|
|
406
479
|
const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null;
|
|
407
|
-
const applyResult = api.setNamedSecurityInfoW(path, 1, 4, null, null, newAcl, null);
|
|
480
|
+
const applyResult = api.setNamedSecurityInfoW(path, 1, labelEdit.kind === "keep" ? 4 : 20, null, null, newAcl, labelEdit.kind === "apply" ? labelEdit.acl : null);
|
|
408
481
|
const freedNew = api.localFree(newAcl);
|
|
482
|
+
const freedLabel = labelEdit.kind === "apply" ? api.localFree(labelEdit.acl) : null;
|
|
409
483
|
if (applyResult !== 0) throwWin32(api, "SetNamedSecurityInfoW", applyResult, `${label}(${path})`);
|
|
410
484
|
if (freedDescriptor !== null && !isNullPtr$1(freedDescriptor)) throwLastError$1(api, "LocalFree", `${label}(${path}) descriptor`);
|
|
411
485
|
if (!isNullPtr$1(freedNew)) throwLastError$1(api, "LocalFree", `${label}(${path}) new ACL`);
|
|
486
|
+
if (freedLabel !== null && !isNullPtr$1(freedLabel)) throwLastError$1(api, "LocalFree", `${label}(${path}) label ACL`);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* True when the explicit DACL already carries the EXACT entry
|
|
490
|
+
* `(aceType, inheritance, mask, trustee SID)`. Every field is read through
|
|
491
|
+
* koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. The
|
|
492
|
+
* ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there is
|
|
493
|
+
* no pointer to read; reading one yields garbage addresses and crashed
|
|
494
|
+
* EqualSid, verified by gdb), so it is compared field-by-field against the
|
|
495
|
+
* trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and
|
|
496
|
+
* denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no
|
|
497
|
+
* exact entry" so the caller falls back to the merge-apply path, which owns
|
|
498
|
+
* the robust failure handling.
|
|
499
|
+
* @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}).
|
|
500
|
+
* @param aceType - the ACE type to match.
|
|
501
|
+
* @param inheritance - the ACE inheritance flags to match.
|
|
502
|
+
* @param mask - the access mask to match.
|
|
503
|
+
* @param sidPtr - the trustee SID to match.
|
|
504
|
+
* @returns whether the exact entry is already present.
|
|
505
|
+
*/
|
|
506
|
+
function hasExactEntry(acl, aceType, inheritance, mask, sidPtr) {
|
|
507
|
+
const aclSize = decodeUint16At(acl, 2);
|
|
508
|
+
const aceCount = decodeUint16At(acl, 4);
|
|
509
|
+
if (aclSize < 8 || aclSize > 1048576) return false;
|
|
510
|
+
let offset = 8;
|
|
511
|
+
for (let index = 0; index < aceCount; index++) {
|
|
512
|
+
const aceSize = decodeUint16At(acl, offset + 2);
|
|
513
|
+
if (aceSize < 8 || offset + aceSize > aclSize) return false;
|
|
514
|
+
if (decodeUint8At(acl, offset) === aceType && decodeUint8At(acl, offset + 1) === inheritance && decodeUint32At(acl, offset + 4) === mask && sameSidAt(acl, offset + 8, sidPtr, 0)) return true;
|
|
515
|
+
offset += aceSize;
|
|
516
|
+
}
|
|
517
|
+
return false;
|
|
412
518
|
}
|
|
413
519
|
/**
|
|
414
520
|
* True when the explicit DACL already carries the EXACT write grant this
|
|
415
|
-
* module would add
|
|
416
|
-
* capability SID
|
|
417
|
-
*
|
|
418
|
-
* ACE after the 4-byte mask — there is no pointer to read; reading one
|
|
419
|
-
* yields garbage addresses and crashed EqualSid, verified by gdb), so it is
|
|
420
|
-
* compared field-by-field against the capability SID through bounded offset
|
|
421
|
-
* reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
|
|
422
|
-
* so the caller falls back to the merge-apply path, which owns the robust
|
|
423
|
-
* failure handling.
|
|
424
|
-
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
|
|
521
|
+
* module would add: the Allow ACE for {@link abi.GRANT_MASK} naming the
|
|
522
|
+
* capability SID.
|
|
523
|
+
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}).
|
|
425
524
|
* @param sidPtr - the capability SID to match.
|
|
426
525
|
* @returns whether the exact grant ACE is already present.
|
|
427
526
|
*/
|
|
428
527
|
function hasExactGrant(oldAcl, sidPtr) {
|
|
528
|
+
return hasExactEntry(oldAcl, 0, 3, GRANT_MASK, sidPtr);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* True when the explicit DACL already carries the EXACT ambient-delete deny:
|
|
532
|
+
* the container-inherited Deny ACE for {@link abi.FILE_DELETE_CHILD} naming
|
|
533
|
+
* the world SID. It is part of the idempotent skip, so a root granted by an
|
|
534
|
+
* earlier build receives the deny on its next provision.
|
|
535
|
+
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}).
|
|
536
|
+
* @param worldSidPtr - the Everyone SID the deny names.
|
|
537
|
+
* @returns whether the exact deny ACE is already present.
|
|
538
|
+
*/
|
|
539
|
+
function hasExactDeny(oldAcl, worldSidPtr) {
|
|
540
|
+
return hasExactEntry(oldAcl, 1, 2, 64, worldSidPtr);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* True when a capability grant for a SID OTHER than `sidPtr` stands on this
|
|
544
|
+
* DACL — the condition under which a revoke must leave the shared Low label in
|
|
545
|
+
* place, or the remaining grant's child would lose its write authority.
|
|
546
|
+
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}).
|
|
547
|
+
* @param sidPtr - the capability SID being revoked.
|
|
548
|
+
* @returns whether another capability grant remains.
|
|
549
|
+
*/
|
|
550
|
+
function hasForeignGrant(oldAcl, sidPtr) {
|
|
429
551
|
const aclSize = decodeUint16At(oldAcl, 2);
|
|
430
552
|
const aceCount = decodeUint16At(oldAcl, 4);
|
|
431
553
|
if (aclSize < 8 || aclSize > 1048576) return false;
|
|
@@ -433,46 +555,67 @@ function hasExactGrant(oldAcl, sidPtr) {
|
|
|
433
555
|
for (let index = 0; index < aceCount; index++) {
|
|
434
556
|
const aceSize = decodeUint16At(oldAcl, offset + 2);
|
|
435
557
|
if (aceSize < 8 || offset + aceSize > aclSize) return false;
|
|
436
|
-
if (decodeUint8At(oldAcl, offset) === 0 &&
|
|
558
|
+
if (decodeUint8At(oldAcl, offset) === 0 && decodeUint32At(oldAcl, offset + 4) === 1114454 && !sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true;
|
|
437
559
|
offset += aceSize;
|
|
438
560
|
}
|
|
439
561
|
return false;
|
|
440
562
|
}
|
|
441
563
|
/**
|
|
442
564
|
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID
|
|
443
|
-
* on `path`,
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
565
|
+
* on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and
|
|
566
|
+
* apply the Low mandatory label — one merge. The deny inherits to containers
|
|
567
|
+
* only: the right is evaluated on directories, and inheriting its bit onto
|
|
568
|
+
* files would deny every `FILE_ALL_ACCESS`/`GENERIC_ALL` open inside the root
|
|
569
|
+
* (0x40 is a member of that mask). The capability ACE's DELETE bit is then the
|
|
570
|
+
* only delete authority inside the root, so a file whose own DACL grants no
|
|
571
|
+
* DELETE is no longer deletable through its parent's rights.
|
|
572
|
+
*
|
|
573
|
+
* Idempotent: the exact ACE, deny, and label together SKIP the
|
|
574
|
+
* SetNamedSecurityInfoW apply, which would otherwise re-propagate the
|
|
575
|
+
* identical descriptor across the whole tree (eager inheritance; minutes on
|
|
576
|
+
* large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs
|
|
577
|
+
* survive (same shape as {@link revokeWrite}). Runs under the per-path lock.
|
|
578
|
+
* The directory must be owned by the caller AND grant WRITE_OWNER (the label
|
|
579
|
+
* lives in the SACL; owner-implicit rights cover only READ_CONTROL and
|
|
580
|
+
* WRITE_DAC) — a Full-control workspace satisfies both.
|
|
453
581
|
* @param api - the binding table.
|
|
454
|
-
* @param path - the directory whose DACL
|
|
582
|
+
* @param path - the directory whose DACL and label gain the grant (the workspace or temp root).
|
|
455
583
|
* @param sidPtr - the capability SID the ACE names.
|
|
584
|
+
* @param lowLabelSidPtr - the Low integrity SID the mandatory label names.
|
|
585
|
+
* @param worldSidPtr - the Everyone SID the ambient-delete deny names.
|
|
456
586
|
*/
|
|
457
|
-
function grantWrite(api, path, sidPtr) {
|
|
587
|
+
function grantWrite(api, path, sidPtr, lowLabelSidPtr, worldSidPtr) {
|
|
458
588
|
withPathLock(api, path, () => {
|
|
459
|
-
const { oldAcl, descriptor } =
|
|
460
|
-
if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) {
|
|
589
|
+
const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path);
|
|
590
|
+
if (oldAcl !== null && labelAcl !== null && hasExactGrant(oldAcl, sidPtr) && hasExactDeny(oldAcl, worldSidPtr) && hasExactLabel(labelAcl, lowLabelSidPtr)) {
|
|
461
591
|
if (descriptor !== null) {
|
|
462
592
|
if (!isNullPtr$1(api.localFree(descriptor))) throwLastError$1(api, "LocalFree", `grantWrite(${path}) descriptor`);
|
|
463
593
|
}
|
|
464
594
|
return;
|
|
465
595
|
}
|
|
466
|
-
|
|
596
|
+
let label;
|
|
597
|
+
try {
|
|
598
|
+
label = buildLowLabelAcl(api, lowLabelSidPtr);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (descriptor !== null) api.localFree(descriptor);
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
mergeAndApply(api, path, Buffer.concat([buildExplicitAccess(worldSidPtr, 3, 64, 2), buildExplicitAccess(sidPtr, 1, GRANT_MASK)]), oldAcl, {
|
|
604
|
+
kind: "apply",
|
|
605
|
+
acl: label
|
|
606
|
+
}, descriptor, "grantWrite");
|
|
467
607
|
});
|
|
468
608
|
}
|
|
469
609
|
/**
|
|
470
610
|
* Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS
|
|
471
|
-
* merge — other entries are preserved).
|
|
472
|
-
*
|
|
611
|
+
* merge — other entries are preserved). The shared Low label is cleared only
|
|
612
|
+
* when no other capability grant remains on the directory: two grants may
|
|
613
|
+
* target one directory, and the surviving one still needs the label for its
|
|
614
|
+
* child's writes. Returns whether an ACE removal was attempted (false when the
|
|
615
|
+
* directory carries no DACL at all).
|
|
473
616
|
*
|
|
474
617
|
* Runs under the per-path lock (the whole get-merge-set sequence); the
|
|
475
|
-
* descriptor/ACL allocation contract lives on {@link
|
|
618
|
+
* descriptor/ACL allocation contract lives on {@link readCurrentSecurity}.
|
|
476
619
|
* @param api - the binding table.
|
|
477
620
|
* @param path - the directory whose DACL loses the capability-SID ACEs.
|
|
478
621
|
* @param sidPtr - the capability SID whose ACEs are removed.
|
|
@@ -480,14 +623,14 @@ function grantWrite(api, path, sidPtr) {
|
|
|
480
623
|
*/
|
|
481
624
|
function revokeWrite(api, path, sidPtr) {
|
|
482
625
|
return withPathLock(api, path, () => {
|
|
483
|
-
const { oldAcl, descriptor } =
|
|
626
|
+
const { oldAcl, descriptor } = readCurrentSecurity(api, path);
|
|
484
627
|
if (oldAcl === null) {
|
|
485
628
|
if (descriptor !== null) {
|
|
486
629
|
if (!isNullPtr$1(api.localFree(descriptor))) throwLastError$1(api, "LocalFree", `revokeWrite(${path}) descriptor`);
|
|
487
630
|
}
|
|
488
631
|
return false;
|
|
489
632
|
}
|
|
490
|
-
mergeAndApply(api, path, buildExplicitAccess(sidPtr, 4, 0), oldAcl, descriptor, "revokeWrite");
|
|
633
|
+
mergeAndApply(api, path, buildExplicitAccess(sidPtr, 4, 0), oldAcl, hasForeignGrant(oldAcl, sidPtr) ? { kind: "keep" } : { kind: "clear" }, descriptor, "revokeWrite");
|
|
491
634
|
return true;
|
|
492
635
|
});
|
|
493
636
|
}
|
|
@@ -673,6 +816,23 @@ function setTokenDefaultDaclGrant(api, token, sidPtr) {
|
|
|
673
816
|
}
|
|
674
817
|
api.localFree(newDacl);
|
|
675
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Lower the restricted token's integrity level to Low (S-1-16-4096), the level
|
|
821
|
+
* the mandatory labels `grantWrite` applies are matched against; a token left
|
|
822
|
+
* at Medium would ignore them. Requires TOKEN_ADJUST_DEFAULT on the token;
|
|
823
|
+
* fails closed before any child is spawned.
|
|
824
|
+
* @param api - the binding table.
|
|
825
|
+
* @param token - the restricted token to lower.
|
|
826
|
+
* @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096).
|
|
827
|
+
*/
|
|
828
|
+
function restrictTokenIntegrity(api, token, lowLabelSidPtr) {
|
|
829
|
+
const sidLength = api.getLengthSid(lowLabelSidPtr);
|
|
830
|
+
if (sidLength === 0) throwLastError$1(api, "GetLengthSid", "Low integrity label SID");
|
|
831
|
+
const info = Buffer.alloc(16 + sidLength);
|
|
832
|
+
info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0);
|
|
833
|
+
info.writeUInt32LE(32, 8);
|
|
834
|
+
if (api.setTokenInformation(token, 25, info, info.length) === 0) throwLastError$1(api, "SetTokenInformation", "TokenIntegrityLevel (Low)");
|
|
835
|
+
}
|
|
676
836
|
/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
|
|
677
837
|
function buildRestrictingSids(sids) {
|
|
678
838
|
const buffer = Buffer.alloc(16 * sids.length);
|
|
@@ -741,34 +901,42 @@ function createRestrictedToken(api, currentToken, logonSid, writeSids, known, mo
|
|
|
741
901
|
*
|
|
742
902
|
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
|
743
903
|
* instance (revoking every path granted so far); `dispose` revokes every
|
|
744
|
-
*
|
|
904
|
+
* revocable grant, leaves the standing workspace edits in place, and reports
|
|
905
|
+
* every cleanup failure.
|
|
745
906
|
* @module @deepseek-ai/dsh-sandbox-windows-acl/grant
|
|
746
907
|
*/
|
|
747
908
|
/**
|
|
748
909
|
* One write SID's provider-lifetime grant materialization: the parsed SID
|
|
749
|
-
* pointer plus every directory whose DACL currently carries its ACE
|
|
750
|
-
*
|
|
751
|
-
*
|
|
910
|
+
* pointer plus every directory whose DACL currently carries its ACE and whose
|
|
911
|
+
* label ACL carries the Low mandatory label. Workspace paths are added
|
|
912
|
+
* STANDING (their security descriptor edits are the cross-session reuse cache
|
|
913
|
+
* and outlive the grant — dispose() skips revoking them, or the next
|
|
752
914
|
* provision would re-propagate the whole tree); temp paths are revocable
|
|
753
915
|
* (dispose() revokes them — an inheritable ACE must not outlive its
|
|
754
916
|
* session's temp directory). Create with {@link AclWriteGrant.create};
|
|
755
|
-
* dispose revokes the revocable paths and frees
|
|
917
|
+
* dispose revokes the revocable paths and frees every SID.
|
|
756
918
|
*/
|
|
757
919
|
var AclWriteGrant = class AclWriteGrant {
|
|
758
920
|
/** The write SID in SDDL string form. */
|
|
759
921
|
writeSid;
|
|
760
922
|
api;
|
|
761
923
|
sidPtr;
|
|
924
|
+
lowLabelSidPtr;
|
|
925
|
+
worldSidPtr;
|
|
762
926
|
revocablePaths = [];
|
|
763
927
|
standingPaths = [];
|
|
764
|
-
constructor(api, sidPtr, writeSid) {
|
|
928
|
+
constructor(api, sidPtr, lowLabelSidPtr, worldSidPtr, writeSid) {
|
|
765
929
|
this.api = api;
|
|
766
930
|
this.sidPtr = sidPtr;
|
|
931
|
+
this.lowLabelSidPtr = lowLabelSidPtr;
|
|
932
|
+
this.worldSidPtr = worldSidPtr;
|
|
767
933
|
this.writeSid = writeSid;
|
|
768
934
|
}
|
|
769
935
|
/**
|
|
770
|
-
* Parse the SID string
|
|
771
|
-
*
|
|
936
|
+
* Parse the SID string, create the Low integrity SID the grants label with
|
|
937
|
+
* and the world SID their ambient-delete deny names, and open the binding
|
|
938
|
+
* table (lazily, once per server). Fail-closed: any failure throws — nothing
|
|
939
|
+
* is granted yet.
|
|
772
940
|
* @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
|
|
773
941
|
* @param api - optional already-resolved bindings (tests).
|
|
774
942
|
* @returns the ready grant (no ACEs yet).
|
|
@@ -779,31 +947,43 @@ var AclWriteGrant = class AclWriteGrant {
|
|
|
779
947
|
if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) throwLastError$1(bindings, "ConvertStringSidToSidW", writeSid);
|
|
780
948
|
const sidPtr = decodePtr(sidSlot);
|
|
781
949
|
if (sidPtr === null) throwLastError$1(bindings, "ConvertStringSidToSidW", `null SID for ${writeSid}`);
|
|
782
|
-
|
|
950
|
+
try {
|
|
951
|
+
const lowLabelSidPtr = makeWellKnownSid(bindings, 66);
|
|
952
|
+
try {
|
|
953
|
+
return new AclWriteGrant(bindings, sidPtr, lowLabelSidPtr, makeWellKnownSid(bindings, 1), writeSid);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
bindings.localFree(lowLabelSidPtr);
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
958
|
+
} catch (error) {
|
|
959
|
+
bindings.localFree(sidPtr);
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
783
962
|
}
|
|
784
963
|
/**
|
|
785
|
-
* Grant the write ACE
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
* revoke it, and revoking an
|
|
791
|
-
* treat a throw as a failed
|
|
792
|
-
* revoke the paths granted so
|
|
793
|
-
*
|
|
794
|
-
* @param
|
|
964
|
+
* Grant the write ACE, the ambient-delete deny, and the Low mandatory label
|
|
965
|
+
* on one directory (idempotent: an already-standing exact ACE, deny, and
|
|
966
|
+
* label skip the eager full-tree re-propagation — see {@link grantWrite})
|
|
967
|
+
* and record the path for {@link dispose} unless it is standing. The path is
|
|
968
|
+
* recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
|
|
969
|
+
* SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
|
|
970
|
+
* ungranted path is a no-op merge. Callers treat a throw as a failed
|
|
971
|
+
* materialization and dispose the instance to revoke the paths granted so
|
|
972
|
+
* far.
|
|
973
|
+
* @param path - the directory whose DACL and label gain the grant.
|
|
974
|
+
* @param standing - the edits outlive this grant (the workspace reuse
|
|
795
975
|
* cache; dispose() skips revoking it). Default false (revoked on
|
|
796
976
|
* dispose — the temp-directory lifecycle).
|
|
797
977
|
*/
|
|
798
978
|
add(path, standing = false) {
|
|
799
979
|
(standing ? this.standingPaths : this.revocablePaths).push(path);
|
|
800
|
-
grantWrite(this.api, path, this.sidPtr);
|
|
980
|
+
grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr);
|
|
801
981
|
}
|
|
802
982
|
/** Every directory currently carrying the grant, in grant order. */
|
|
803
983
|
get paths() {
|
|
804
984
|
return [...this.standingPaths, ...this.revocablePaths];
|
|
805
985
|
}
|
|
806
|
-
/** Revoke every revocable grant (standing
|
|
986
|
+
/** Revoke every revocable grant (standing security descriptor edits stay) and free the SIDs; reports every cleanup failure. */
|
|
807
987
|
dispose() {
|
|
808
988
|
const failures = [];
|
|
809
989
|
for (const path of this.revocablePaths) try {
|
|
@@ -811,8 +991,12 @@ var AclWriteGrant = class AclWriteGrant {
|
|
|
811
991
|
} catch (error) {
|
|
812
992
|
failures.push(error);
|
|
813
993
|
}
|
|
814
|
-
|
|
815
|
-
|
|
994
|
+
for (const [label, sidPtr] of [
|
|
995
|
+
["write SID", this.sidPtr],
|
|
996
|
+
["Low label SID", this.lowLabelSidPtr],
|
|
997
|
+
["world SID", this.worldSidPtr]
|
|
998
|
+
]) try {
|
|
999
|
+
if (!isNullPtr$1(this.api.localFree(sidPtr))) throwLastError$1(this.api, "LocalFree", label);
|
|
816
1000
|
} catch (error) {
|
|
817
1001
|
failures.push(error);
|
|
818
1002
|
}
|
|
@@ -881,7 +1065,10 @@ function tempWriteSid(tempDir) {
|
|
|
881
1065
|
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
|
|
882
1066
|
* and LOCAL are absent from both lists — see the seam's dual-list contract
|
|
883
1067
|
* in `packages/sandbox/sandbox-local` and the package README's Modes section
|
|
884
|
-
* for the complete boundary). The
|
|
1068
|
+
* for the complete boundary). The intersection covers only the object's own
|
|
1069
|
+
* access check, so the token is also lowered to Low integrity and every
|
|
1070
|
+
* granted directory carries a Low no-write-up label and the ambient-delete
|
|
1071
|
+
* deny the `acl` module documents. The write SID is the per-WORKSPACE identity
|
|
885
1072
|
* ({@link workspaceWriteSid}): deterministic from the canonical workspace
|
|
886
1073
|
* path, so the workspace-root ACE materializes once per workspace per
|
|
887
1074
|
* machine and every later provision hits the exact-ACE skip — the
|
|
@@ -996,23 +1183,25 @@ var AclSandbox = class {
|
|
|
996
1183
|
assertPrivateTempDisjoint(this.writableDirs, tempDir);
|
|
997
1184
|
}
|
|
998
1185
|
this.tempDirResolved = tempDir;
|
|
1186
|
+
const lowLabelSid = makeWellKnownSid(api, 66);
|
|
1187
|
+
const worldSid = makeWellKnownSid(api, 1);
|
|
1188
|
+
this.sidAllocations.push(lowLabelSid, worldSid);
|
|
999
1189
|
if (this.manageDacls) {
|
|
1000
1190
|
if (this.writeSidPtr !== void 0) {
|
|
1001
|
-
for (const path of this.writableDirs) grantWrite(api, path, this.writeSidPtr);
|
|
1191
|
+
for (const path of this.writableDirs) grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid);
|
|
1002
1192
|
if (tempDir !== null && this.tempWriteSidPtr !== void 0) {
|
|
1003
1193
|
this.grantedPaths.push({
|
|
1004
1194
|
path: tempDir,
|
|
1005
1195
|
sidPtr: this.tempWriteSidPtr
|
|
1006
1196
|
});
|
|
1007
|
-
grantWrite(api, tempDir, this.tempWriteSidPtr);
|
|
1197
|
+
grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid, worldSid);
|
|
1008
1198
|
}
|
|
1009
1199
|
}
|
|
1010
1200
|
}
|
|
1011
1201
|
const logonSid = findLogonSid(api, currentToken);
|
|
1012
1202
|
this.sidAllocations.push(logonSid);
|
|
1013
|
-
const worldSid = makeWellKnownSid(api, 1);
|
|
1014
|
-
this.sidAllocations.push(worldSid);
|
|
1015
1203
|
restrictedToken = createRestrictedToken(api, currentToken, logonSid, [this.writeSidPtr, this.tempWriteSidPtr].filter((sid) => sid !== void 0), { world: worldSid }, this.mode);
|
|
1204
|
+
restrictTokenIntegrity(api, restrictedToken, lowLabelSid);
|
|
1016
1205
|
this.token = restrictedToken;
|
|
1017
1206
|
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid);
|
|
1018
1207
|
if (api.closeHandle(currentToken) === 0) throwLastError$1(api, "CloseHandle", "current process token");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
|
|
3
3
|
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7-alpha.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
],
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@deepseek-ai/cordis": "
|
|
37
|
-
"@deepseek-ai/dsh-subprocess": "
|
|
36
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
37
|
+
"@deepseek-ai/dsh-subprocess": "0.1.7-alpha.2"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"koffi": "^3.1.0",
|
|
41
|
-
"@deepseek-ai/dsh-lazy-require": "
|
|
42
|
-
"@deepseek-ai/dsh-win32-process": "
|
|
41
|
+
"@deepseek-ai/dsh-lazy-require": "0.1.7-alpha.2",
|
|
42
|
+
"@deepseek-ai/dsh-win32-process": "0.1.7-alpha.2"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@deepseek-ai/dsh-pwsh-local": "
|
|
46
|
-
"@deepseek-ai/dsh-sandbox-local": "
|
|
47
|
-
"@deepseek-ai/cordis": "
|
|
48
|
-
"@deepseek-ai/dsh-subprocess": "
|
|
49
|
-
"@deepseek-ai/dsh-subprocess-local": "
|
|
45
|
+
"@deepseek-ai/dsh-pwsh-local": "0.1.7-alpha.2",
|
|
46
|
+
"@deepseek-ai/dsh-sandbox-local": "0.1.7-alpha.2",
|
|
47
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
48
|
+
"@deepseek-ai/dsh-subprocess": "0.1.7-alpha.2",
|
|
49
|
+
"@deepseek-ai/dsh-subprocess-local": "0.1.7-alpha.2"
|
|
50
50
|
}
|
|
51
51
|
}
|