@deepseek-ai/dsh-sandbox-windows-acl 0.0.1-rc.1
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/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +91 -0
- package/README.zh.md +93 -0
- package/lib/index.js +2 -0
- package/lib/invariant.js +24 -0
- package/lib/runner.js +150 -0
- package/lib/types/acl.d.ts +83 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/ffi.d.ts +252 -0
- package/lib/types/grant.d.ts +63 -0
- package/lib/types/index.d.ts +157 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/runner.d.ts +51 -0
- package/lib/types/spawn.d.ts +103 -0
- package/lib/types/token.d.ts +89 -0
- package/lib/types/win32-abi.d.ts +178 -0
- package/lib/types/workspace-sid.d.ts +31 -0
- package/lib/types-Br60qOzH.js +1545 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
|
|
5
|
+
README.md: b13160f7490878143c719ca617936b74ffd298af
|
|
6
|
+
README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-sandbox-windows-acl
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
|
6
|
+
|
|
7
|
+
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary).
|
|
8
|
+
|
|
9
|
+
Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all).
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
|
15
|
+
|
|
16
|
+
const workspaceRoot = process.cwd()
|
|
17
|
+
|
|
18
|
+
// mode selects the token's restricting-SID list (see Modes below) and must
|
|
19
|
+
// match the grant shape: read-only pairs with zero grants. workspace-write
|
|
20
|
+
// REQUIRES the workspace's write SID — the per-workspace identity.
|
|
21
|
+
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
|
22
|
+
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
|
23
|
+
|
|
24
|
+
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
|
25
|
+
const { stdout, stderr, exitCode } = await child.wait()
|
|
26
|
+
|
|
27
|
+
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction.
|
|
31
|
+
|
|
32
|
+
## The confinement runner
|
|
33
|
+
|
|
34
|
+
The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial.
|
|
41
|
+
|
|
42
|
+
**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host).
|
|
43
|
+
|
|
44
|
+
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
|
45
|
+
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection.
|
|
46
|
+
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the 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 — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
|
47
|
+
|
|
48
|
+
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
|
|
49
|
+
|
|
50
|
+
The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle.
|
|
51
|
+
|
|
52
|
+
## Header verification
|
|
53
|
+
|
|
54
|
+
All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts):
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory.
|
|
61
|
+
|
|
62
|
+
## Verified boundaries (inherent to restricted tokens, not this port)
|
|
63
|
+
|
|
64
|
+
- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement.
|
|
65
|
+
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
|
|
66
|
+
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls <dir> /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace.
|
|
67
|
+
- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation.
|
|
68
|
+
- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead.
|
|
69
|
+
- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation.
|
|
70
|
+
- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected.
|
|
71
|
+
|
|
72
|
+
## Model Experience
|
|
73
|
+
|
|
74
|
+
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection.
|
|
75
|
+
|
|
76
|
+
#### KV Cache effect
|
|
77
|
+
|
|
78
|
+
None directly; the denial surface belongs to the tool layer.
|
|
79
|
+
|
|
80
|
+
## Known Limitations and Deferred Work
|
|
81
|
+
|
|
82
|
+
- **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 (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
|
|
83
|
+
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove.
|
|
84
|
+
- **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). A future cleanup command may reap them; nothing re-propagates because of them.
|
|
85
|
+
- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path.
|
|
86
|
+
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
|
|
87
|
+
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
|
|
88
|
+
- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this.
|
|
89
|
+
- **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.
|
|
90
|
+
- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated.
|
|
91
|
+
- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-sandbox-windows-acl
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。
|
|
6
|
+
|
|
7
|
+
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。
|
|
8
|
+
|
|
9
|
+
直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
|
|
10
|
+
|
|
11
|
+
## 用法
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
|
15
|
+
|
|
16
|
+
const workspaceRoot = process.cwd()
|
|
17
|
+
|
|
18
|
+
// mode selects the token's restricting-SID list (see Modes below) and must
|
|
19
|
+
// match the grant shape: read-only pairs with zero grants. workspace-write
|
|
20
|
+
// REQUIRES the workspace's write SID — the per-workspace identity.
|
|
21
|
+
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
|
22
|
+
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
|
23
|
+
|
|
24
|
+
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
|
25
|
+
const { stdout, stderr, exitCode } = await child.wait()
|
|
26
|
+
|
|
27
|
+
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。
|
|
31
|
+
|
|
32
|
+
<a id="the-confinement-runner"></a>
|
|
33
|
+
|
|
34
|
+
## 隔离 runner
|
|
35
|
+
|
|
36
|
+
面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
|
|
43
|
+
|
|
44
|
+
**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。
|
|
45
|
+
|
|
46
|
+
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
|
|
47
|
+
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
|
|
48
|
+
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
|
49
|
+
|
|
50
|
+
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
|
|
51
|
+
|
|
52
|
+
`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。
|
|
53
|
+
|
|
54
|
+
## 头部验证
|
|
55
|
+
|
|
56
|
+
所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。
|
|
63
|
+
|
|
64
|
+
## 已验证边界(受限令牌固有,非本移植引入)
|
|
65
|
+
|
|
66
|
+
- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。
|
|
67
|
+
- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。
|
|
68
|
+
- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
|
|
69
|
+
- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。
|
|
70
|
+
- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`<temp>\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。
|
|
71
|
+
- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。
|
|
72
|
+
- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。
|
|
73
|
+
|
|
74
|
+
## Model Experience
|
|
75
|
+
|
|
76
|
+
间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
|
|
77
|
+
|
|
78
|
+
#### KV Cache 影响
|
|
79
|
+
|
|
80
|
+
无直接影响;拒绝面属于工具层。
|
|
81
|
+
|
|
82
|
+
## Known Limitations and Deferred Work
|
|
83
|
+
|
|
84
|
+
- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。
|
|
85
|
+
- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。
|
|
86
|
+
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。
|
|
87
|
+
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。
|
|
88
|
+
- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
|
|
89
|
+
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
|
90
|
+
- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。
|
|
91
|
+
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
|
92
|
+
- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。
|
|
93
|
+
- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。
|
package/lib/index.js
ADDED
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`.
|
|
4
|
+
* @module @deepseek-ai/dsh-sandbox-windows-acl/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-sandbox-windows-acl";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "sandbox-windows-acl-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this package exposes no independent event sequence or
|
|
13
|
+
* mutable data relation beyond the fail-closed contracts it enforces at each
|
|
14
|
+
* Win32 call boundary.
|
|
15
|
+
*/
|
|
16
|
+
const install = () => {};
|
|
17
|
+
/**
|
|
18
|
+
* Register this package's invariant companion.
|
|
19
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
20
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
21
|
+
*/
|
|
22
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
23
|
+
//#endregion
|
|
24
|
+
export { apply, inject, name };
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { a as win32, n as workspaceWriteSid, t as AclSandbox } from "./types-Br60qOzH.js";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
//#region lib/types/runner.js
|
|
4
|
+
/**
|
|
5
|
+
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
|
|
6
|
+
* seam spawns in place of the caller's command. It creates the
|
|
7
|
+
* WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
|
|
8
|
+
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
|
|
9
|
+
* straight through), mirrors the child's exit code, and revokes its temp
|
|
10
|
+
* grant on exit (workspace ACEs stay standing as the reuse cache).
|
|
11
|
+
*
|
|
12
|
+
* Stable argv contract (the seam builds it; a native-exe replacement would
|
|
13
|
+
* keep the same contract):
|
|
14
|
+
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
|
|
15
|
+
* '--mode', <read-only|workspace-write>,
|
|
16
|
+
* ['--write-sid', <S-1-4-…>], '--', <argv...>]
|
|
17
|
+
*
|
|
18
|
+
* Modes:
|
|
19
|
+
* - workspace-write: the workspace and temp directories carry the orphan-SID
|
|
20
|
+
* Write grant; every other write is denied by the token intersection.
|
|
21
|
+
* - read-only: STRICT zero grants — no directory is writable, not even the
|
|
22
|
+
* NUL device (`> $null` fails with access denied); the restricting list
|
|
23
|
+
* carries no orphan SID, so a standing grant ACE from an earlier
|
|
24
|
+
* workspace-write period stays inert. BOTH modes drop Authenticated Users
|
|
25
|
+
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
|
|
26
|
+
* Public tree writes are denied); the two lists share the keep-alive group
|
|
27
|
+
* (logon SID, EVERYONE) and differ only by the orphan.
|
|
28
|
+
*
|
|
29
|
+
* `--write-sid`: the seam's grant contract — the CALLER has already
|
|
30
|
+
* materialized the write-SID ACEs (the seam's workspace + private-temp
|
|
31
|
+
* grants, server lifetime) and owns their revocation, so the runner neither
|
|
32
|
+
* grants nor revokes (manageDacls: false). The carried SID is the
|
|
33
|
+
* per-workspace identity ({@link workspaceWriteSid}) — the seam derives it
|
|
34
|
+
* from the policy root; the flag's PRESENCE is the seam-managed marker (its
|
|
35
|
+
* value must equal the workspace-derived SID). Absent `--write-sid`
|
|
36
|
+
* (standalone/test use) the runner self-manages grants per invocation with
|
|
37
|
+
* the same workspace-derived SID (its workspace ACEs are standing — the
|
|
38
|
+
* reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in
|
|
39
|
+
* workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN
|
|
40
|
+
* environment (SetEnvironmentVariableW) to the `--temp` directory — a
|
|
41
|
+
* PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs
|
|
42
|
+
* /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment
|
|
43
|
+
* NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in
|
|
44
|
+
* CreateProcessAsUserW, verified empirically). Read-only leaves the ambient
|
|
45
|
+
* temp entries untouched (writes there are denied anyway).
|
|
46
|
+
*
|
|
47
|
+
* Failure contract: every runner-side failure (bad args, missing
|
|
48
|
+
* directories, token/grant/spawn errors) prints `windows-acl-run: <detail>`
|
|
49
|
+
* to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that
|
|
50
|
+
* signature. The child is NEVER spawned unrestricted.
|
|
51
|
+
* @module @deepseek-ai/dsh-sandbox-windows-acl/runner
|
|
52
|
+
*/
|
|
53
|
+
const RUNNER_SIGNATURE = "windows-acl-run";
|
|
54
|
+
const RUNNER_FAILURE_EXIT = 127;
|
|
55
|
+
var RunnerFailure = class extends Error {};
|
|
56
|
+
/** Print the runner-failure signature line and unwind. */
|
|
57
|
+
function fail(detail) {
|
|
58
|
+
process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`);
|
|
59
|
+
throw new RunnerFailure(detail);
|
|
60
|
+
}
|
|
61
|
+
function parseArgs(raw) {
|
|
62
|
+
let workspace;
|
|
63
|
+
let temp;
|
|
64
|
+
let mode;
|
|
65
|
+
let writeSid;
|
|
66
|
+
let index = 0;
|
|
67
|
+
for (; index < raw.length; index++) {
|
|
68
|
+
const token = raw[index];
|
|
69
|
+
if (token === "--") {
|
|
70
|
+
index++;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
index++;
|
|
74
|
+
const value = raw[index];
|
|
75
|
+
if (value === void 0) fail(`missing value after ${token}`);
|
|
76
|
+
switch (token) {
|
|
77
|
+
case "--workspace":
|
|
78
|
+
workspace = value;
|
|
79
|
+
break;
|
|
80
|
+
case "--temp":
|
|
81
|
+
temp = value;
|
|
82
|
+
break;
|
|
83
|
+
case "--mode":
|
|
84
|
+
mode = value;
|
|
85
|
+
break;
|
|
86
|
+
case "--write-sid":
|
|
87
|
+
writeSid = value;
|
|
88
|
+
break;
|
|
89
|
+
default: fail(`unknown argument: ${token}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (workspace === void 0) fail("missing --workspace");
|
|
93
|
+
if (temp === void 0) fail("missing --temp");
|
|
94
|
+
if (mode !== "read-only" && mode !== "workspace-write") fail(`unknown mode: ${String(mode)}`);
|
|
95
|
+
const argv = raw.slice(index);
|
|
96
|
+
const command = argv[0];
|
|
97
|
+
if (command === void 0) fail("missing command after --");
|
|
98
|
+
return {
|
|
99
|
+
workspace,
|
|
100
|
+
temp,
|
|
101
|
+
mode,
|
|
102
|
+
writeSid,
|
|
103
|
+
command,
|
|
104
|
+
args: argv.slice(1)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function requireDirectory(label, path) {
|
|
108
|
+
if (!existsSync(path) || !statSync(path).isDirectory()) fail(`${label} is not an existing directory: ${path}`);
|
|
109
|
+
}
|
|
110
|
+
async function main() {
|
|
111
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
112
|
+
requireDirectory("--workspace", parsed.workspace);
|
|
113
|
+
requireDirectory("--temp", parsed.temp);
|
|
114
|
+
const api = await win32();
|
|
115
|
+
if (api.setConsoleCtrlHandler(null, 1) === 0) fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`);
|
|
116
|
+
const writeSid = parsed.mode === "workspace-write" ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : void 0;
|
|
117
|
+
const sandbox = new AclSandbox({
|
|
118
|
+
writableDirs: parsed.mode === "workspace-write" ? [parsed.workspace] : [],
|
|
119
|
+
tempDir: parsed.mode === "workspace-write" ? parsed.temp : null,
|
|
120
|
+
mode: parsed.mode,
|
|
121
|
+
...writeSid === void 0 ? {} : { writeSid },
|
|
122
|
+
manageDacls: parsed.writeSid === void 0
|
|
123
|
+
});
|
|
124
|
+
await sandbox.init();
|
|
125
|
+
if (parsed.mode === "workspace-write" && parsed.writeSid !== void 0) {
|
|
126
|
+
if (api.setEnvironmentVariableW("TMP", parsed.temp) === 0) fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`);
|
|
127
|
+
if (api.setEnvironmentVariableW("TEMP", parsed.temp) === 0) fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return (await sandbox.spawn({
|
|
131
|
+
command: parsed.command,
|
|
132
|
+
args: parsed.args,
|
|
133
|
+
stdio: "inherit"
|
|
134
|
+
}).wait()).exitCode;
|
|
135
|
+
} finally {
|
|
136
|
+
try {
|
|
137
|
+
sandbox.dispose();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
main().then((exitCode) => {
|
|
144
|
+
process.exitCode = exitCode;
|
|
145
|
+
}, (error) => {
|
|
146
|
+
if (!(error instanceof RunnerFailure)) process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
147
|
+
process.exitCode = RUNNER_FAILURE_EXIT;
|
|
148
|
+
});
|
|
149
|
+
//#endregion
|
|
150
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACL editing helpers: grant/revoke the orphan write SID on a directory via
|
|
3
|
+
* SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
|
|
4
|
+
* the failure handling the POC lacks). Every API call is checked and every
|
|
5
|
+
* failure is reported with the API name, the exact Win32 code, the formatted
|
|
6
|
+
* system text, and the affected path.
|
|
7
|
+
*
|
|
8
|
+
* Concurrency: grants are read-merge-write against the directory's CURRENT
|
|
9
|
+
* DACL, and the whole get-merge-set sequence runs under a per-path exclusive
|
|
10
|
+
* LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
|
|
11
|
+
* cannot clobber each other's ACEs.
|
|
12
|
+
* @module @deepseek-ai/dsh-sandbox-windows-acl/acl
|
|
13
|
+
*/
|
|
14
|
+
import type { NativePtr, Win32Bindings } from './ffi.ts';
|
|
15
|
+
/**
|
|
16
|
+
* Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp):
|
|
17
|
+
* perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
|
|
18
|
+
* MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
|
|
19
|
+
* `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
|
|
20
|
+
* removes every ACE for the trustee.
|
|
21
|
+
* @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).
|
|
24
|
+
* @returns the packed entry buffer.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer;
|
|
27
|
+
/**
|
|
28
|
+
* One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16
|
|
29
|
+
* hex of sha256(lowercased path)>.lock`. The lock root derives from
|
|
30
|
+
* GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing
|
|
31
|
+
* maps Windows's case-insensitive path spellings onto one lock.
|
|
32
|
+
* @param api - the binding table.
|
|
33
|
+
* @param path - the protected directory (absolute).
|
|
34
|
+
* @returns the lock file path for that directory.
|
|
35
|
+
*/
|
|
36
|
+
export declare function lockFilePath(api: Win32Bindings, path: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Run `action` holding the per-path exclusive lock: CreateFileW
|
|
39
|
+
* (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file
|
|
40
|
+
* could be removed and recreated under the holder, letting two processes
|
|
41
|
+
* hold "the same" lock), then a one-byte LockFileEx
|
|
42
|
+
* (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the
|
|
43
|
+
* synchronous handle — see allocOverlapped for why not NULL), then
|
|
44
|
+
* UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures
|
|
45
|
+
* throw like every other Win32 call in this package; an `action` failure
|
|
46
|
+
* still unlocks (best-effort) and rethrows the original error.
|
|
47
|
+
* @param api - the binding table.
|
|
48
|
+
* @param path - the protected directory (absolute).
|
|
49
|
+
* @param action - the get-merge-set sequence to serialize.
|
|
50
|
+
* @returns the action's result.
|
|
51
|
+
*/
|
|
52
|
+
export declare function withPathLock<T>(api: Win32Bindings, path: string, action: () => T): T;
|
|
53
|
+
/**
|
|
54
|
+
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
|
|
55
|
+
* on `path`, inheriting to subcontainers and objects. Idempotent: when the
|
|
56
|
+
* directory's current explicit DACL already carries the exact ACE (the
|
|
57
|
+
* per-session grant surviving from a previous server lifetime), the
|
|
58
|
+
* SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate
|
|
59
|
+
* the identical ACE across the whole tree (eager inheritance; minutes on
|
|
60
|
+
* large workspaces). Otherwise read-merge-write: the new ACE merges into the
|
|
61
|
+
* directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so
|
|
62
|
+
* pre-existing explicit ACEs survive. Runs under the per-path lock. The
|
|
63
|
+
* directory must be owned by the caller (owner implicit WRITE_DAC) — same
|
|
64
|
+
* precondition as the POC.
|
|
65
|
+
* @param api - the binding table.
|
|
66
|
+
* @param path - the directory whose DACL gains the grant (the workspace or temp root).
|
|
67
|
+
* @param sidPtr - the orphan write SID the ACE names.
|
|
68
|
+
*/
|
|
69
|
+
export declare function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void;
|
|
70
|
+
/**
|
|
71
|
+
* Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
|
|
72
|
+
* merge — other entries are preserved). Returns whether an ACE removal was
|
|
73
|
+
* attempted (false when the directory carries no DACL at all).
|
|
74
|
+
*
|
|
75
|
+
* Runs under the per-path lock (the whole get-merge-set sequence); the
|
|
76
|
+
* descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
|
|
77
|
+
* @param api - the binding table.
|
|
78
|
+
* @param path - the directory whose DACL loses the orphan-SID ACEs.
|
|
79
|
+
* @param sidPtr - the orphan write SID whose ACEs are removed.
|
|
80
|
+
* @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
|
|
81
|
+
*/
|
|
82
|
+
export declare function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean;
|
|
83
|
+
//# sourceMappingURL=acl.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed Win32 error type. Every backend API failure raises this with the
|
|
3
|
+
* API name and the exact Win32 code; the original POC silently ignored every
|
|
4
|
+
* failed call and would run children UNRESTRICTED (fail-open) — that is the
|
|
5
|
+
* failure mode this class exists to prevent.
|
|
6
|
+
* @module @deepseek-ai/dsh-sandbox-windows-acl/errors
|
|
7
|
+
*/
|
|
8
|
+
export declare class Win32Error extends Error {
|
|
9
|
+
/** The failing Win32 API name, e.g. `CreateRestrictedToken`. */
|
|
10
|
+
readonly api: string;
|
|
11
|
+
/** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */
|
|
12
|
+
readonly win32Code: number;
|
|
13
|
+
constructor(api: string, win32Code: number, detail?: string);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=errors.d.ts.map
|