fix(sandbox): report Windows ACL limits
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
|
||||
README.md: 23d3a32451c105c71c0a7399ed051288b70753f3
|
||||
README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11
|
||||
README.md: 2d36802305ebe34676d896404913b4686d318b97
|
||||
README.zh.md: 565910781dc0f43635dd22d08fefb32f7f4f27e3
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly.
|
||||
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt; Windows uses the ACL restricted-token runner. Multiple candidates are probed in order, while a sole candidate is selected directly.
|
||||
|
||||
The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal.
|
||||
|
||||
@@ -31,7 +31,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred.
|
||||
- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
|
||||
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
|
||||
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
|
||||
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。
|
||||
[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL 受限令牌 runner。多个候选项会按顺序探测,只有一个候选项时则直接选择。
|
||||
|
||||
包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。
|
||||
|
||||
@@ -31,7 +31,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Windows 没有 runner**:`win32` 以 `SANDBOX_UNAVAILABLE` 拒绝执行;AppContainer 家族后端暂缓实现。
|
||||
- **Windows ACL 只能实现部分强制执行**:受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
|
||||
- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
|
||||
- **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
|
||||
- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
* every later provision O(1) instead of re-propagating the tree per
|
||||
* session); the private-temp ACEs are revoked on dispose. The runner
|
||||
* receives `--write-sid` (the derived identity; its presence marks the
|
||||
* seam-managed contract) and stops managing DACLs itself.
|
||||
* seam-managed contract) and stops managing DACLs itself. The rung reports
|
||||
* partial enforcement because WRITE_RESTRICTED must retain Everyone in its
|
||||
* restricting list and NTFS hard links alias one file object across paths.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
@@ -189,13 +191,12 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> =
|
||||
bwrap: 'full',
|
||||
landlock: 'full',
|
||||
seatbelt: 'full',
|
||||
// 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists
|
||||
// close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are
|
||||
// absent from both — pinned by the runner's Public-probe and CIM-denial
|
||||
// regressions). FAT-class (non-ACL) targets are declared unsupported
|
||||
// (warn-only) in the backend README — outside the promise, not an
|
||||
// exception to it.
|
||||
'windows-acl': 'full',
|
||||
// WRITE_RESTRICTED needs Everyone in both restricting lists for process
|
||||
// initialization. An external object that grants Everyone write access
|
||||
// therefore remains writable, and NTFS hard links can alias a granted
|
||||
// workspace file to a path outside it. The backend enforces the remaining
|
||||
// ACL-addressable surface but must not advertise the absolute promise.
|
||||
'windows-acl': 'partial',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,8 +530,9 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' {
|
||||
// bwrap's mount profile and Seatbelt's deny-file-write* profile govern
|
||||
// every promised file effect by construction, so their passing probes
|
||||
// are always full enforcement; only the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial.
|
||||
// are always full enforcement; the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial, while windows-acl is always
|
||||
// partial for its documented Everyone and hard-link boundaries.
|
||||
switch (runner) {
|
||||
case 'bwrap': {
|
||||
const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs))
|
||||
@@ -547,7 +549,7 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
case 'windows-acl': {
|
||||
const probe = this.internals.probeWindowsAcl
|
||||
?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))
|
||||
return probe() ? 'full' : 'unusable'
|
||||
return probe() ? 'partial' : 'unusable'
|
||||
}
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
|
||||
@@ -381,7 +381,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
|
||||
README.md: b13160f7490878143c719ca617936b74ffd298af
|
||||
README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44
|
||||
README.md: 2abd6239d2888d5d39ea22763b1cc9a29dc6075d
|
||||
README.zh.md: fe1f22e3819d21cacc1ea342684c5e3b88c96f9b
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
||||
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 `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
||||
|
||||
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include 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).
|
||||
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 primary allowlist and grants nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement.
|
||||
|
||||
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).
|
||||
|
||||
@@ -42,8 +42,8 @@ The runner creates the restricted token, spawns the wrapped argv under it with t
|
||||
**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).
|
||||
|
||||
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
||||
- `workspace-write` (logon SID, Everyone, 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.
|
||||
- `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).
|
||||
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries.
|
||||
- `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. 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. Everyone's ambient rights remain the documented partial boundary. 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).
|
||||
|
||||
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).
|
||||
|
||||
@@ -61,6 +61,8 @@ The koffi struct definitions assert their sizes against the probe at module load
|
||||
|
||||
## Verified boundaries (inherent to restricted tokens, not this port)
|
||||
|
||||
- **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary.
|
||||
- **Hard links are file-object aliases, not path aliases.** An inheritable workspace ACE propagated onto an existing NTFS hard link changes the one underlying file security descriptor, so the same object is writable through an external alias. Rejecting every multiply-linked workspace file is not viable for ordinary pnpm installations, which use hard links into their content-addressable store; the native runner suite pins the gap and the provider's partial report names its consequence.
|
||||
- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement.
|
||||
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
|
||||
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; 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.
|
||||
@@ -71,7 +73,7 @@ The koffi struct definitions assert their sizes against the probe at module load
|
||||
|
||||
## Model Experience
|
||||
|
||||
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.
|
||||
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 partial-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.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 [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 后端在同一包中。
|
||||
面向 [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/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。
|
||||
|
||||
一句话机制:把调用者令牌复制为 `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——下文「模式」段是完整边界)。
|
||||
一句话机制:把调用者令牌复制为 `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),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。
|
||||
|
||||
直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
|
||||
|
||||
@@ -44,8 +44,8 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的
|
||||
**按工作区授权复用**(`--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 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。
|
||||
|
||||
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
|
||||
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
|
||||
- `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)。
|
||||
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
|
||||
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 探针回归测试钉住(见设计笔记)。
|
||||
|
||||
@@ -63,6 +63,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
|
||||
## 已验证边界(受限令牌固有,非本移植引入)
|
||||
|
||||
- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。
|
||||
- **硬链接是文件对象别名,而非路径别名。** 传播到已有 NTFS 硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入。拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接指向其内容寻址存储;原生 runner 套件钉住该缺口,提供方的部分强制执行报告则点明其后果。
|
||||
- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。
|
||||
- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。
|
||||
- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
|
||||
@@ -73,7 +75,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接地通过 [`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 选择。
|
||||
间接地通过 [`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 选择。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ export interface AclSandboxOptions {
|
||||
writableDirs: readonly string[]
|
||||
/**
|
||||
* Temp directory to also grant; defaults to GetTempPathW() at init time.
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero grant on
|
||||
* the filesystem; the NUL device stays ambient-writable via Everyone — see
|
||||
* README).
|
||||
* Pass null for read-only confinement: NO explicit temp grant. Ambient
|
||||
* Everyone authority remains part of the backend's documented partial
|
||||
* boundary — see README.
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
*
|
||||
* Modes:
|
||||
* - workspace-write: the workspace and temp directories carry the orphan-SID
|
||||
* Write grant; every other write is denied by the token intersection.
|
||||
* - read-only: STRICT zero grants — no directory is writable, not even the
|
||||
* NUL device (`> $null` fails with access denied); the restricting list
|
||||
* carries no orphan SID, so a standing grant ACE from an earlier
|
||||
* Write grant; other ACL-addressable writes are denied except for the
|
||||
* documented Everyone and hard-link boundaries.
|
||||
* - read-only: no orphan-SID grants; the restricting list carries no orphan
|
||||
* SID, so a standing grant ACE from an earlier
|
||||
* workspace-write period stays inert. BOTH modes drop Authenticated Users
|
||||
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
|
||||
* Public tree writes are denied); the two lists share the keep-alive group
|
||||
|
||||
@@ -170,10 +170,11 @@ export interface RestrictingSidSet {
|
||||
* workspace-write — read-only carries no write SID, so a standing grant ACE
|
||||
* from an earlier workspace-write period (a `/permission` mode downgrade, or
|
||||
* a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
|
||||
* pass-2 check grants only what the restricting list carries, keeping
|
||||
* read-only strictly zero-grant even with stale ACEs standing, while the
|
||||
* unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no
|
||||
* re-propagation). Authenticated Users is absent from BOTH lists: the WMI
|
||||
* pass-2 check grants only what the restricting list carries, keeping that
|
||||
* workspace grant inert under read-only while the unrevoked ACE keeps the
|
||||
* re-upgrade free (the grant's exact-ACE skip — no re-propagation).
|
||||
* Everyone's own ambient grants remain the documented partial boundary.
|
||||
* Authenticated Users is absent from BOTH lists: the WMI
|
||||
* namespace security check fails (0x80041003), so CIM is unavailable in
|
||||
* every confined mode, and the C:\-root tree-creation escape (standing
|
||||
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
|
||||
|
||||
@@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) {
|
||||
}
|
||||
|
||||
describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => {
|
||||
it('workspace-write: runner argv prefix, explicit temp, mode flag, partial enforcement, ACL denial dialect', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => true)
|
||||
const sandbox = await setup({
|
||||
platform: 'win32',
|
||||
@@ -41,7 +41,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
// A sole candidate is selected unprobed.
|
||||
@@ -52,7 +52,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -38,6 +38,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let worldWritableDir!: string
|
||||
// The ambient-writable probe target: a subdirectory of C:\Users\Public.
|
||||
// INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public
|
||||
// tree's INTERACTIVE grant must NOT satisfy the write check — the ambient
|
||||
@@ -54,6 +55,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
worldWritableDir = join(scratchRoot, 'world-writable')
|
||||
mkdirSync(worldWritableDir)
|
||||
const worldGrant = spawnSync('icacls', [worldWritableDir, '/grant', '*S-1-1-0:(OI)(CI)(M)'], { encoding: 'utf8' })
|
||||
if (worldGrant.status !== 0) {
|
||||
throw new Error(`icacls Everyone grant failed: ${worldGrant.stdout}\n${worldGrant.stderr}`)
|
||||
}
|
||||
try {
|
||||
publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-'))
|
||||
} catch {
|
||||
@@ -99,13 +106,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => {
|
||||
it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM unavailable', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
// The NUL device is a securable object: strict zero grants deny it too.
|
||||
// Set-Content NUL fails at the PowerShell/.NET layer even though the
|
||||
// device DACL's Everyone rights remain an ambient backend boundary.
|
||||
'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};',
|
||||
// PowerShell's $null redirection discards without opening NUL — must keep working.
|
||||
'echo hi > $null;\'DOLLAR-NULL: OK\';',
|
||||
@@ -286,6 +294,45 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => {
|
||||
// Everyone is a required keep-alive restricting SID: without it early DLL
|
||||
// initialization and CNG fail. A normal DACL that grants Everyone Modify
|
||||
// therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this
|
||||
// unavoidable gap beside the provider's `partial` enforcement report.
|
||||
for (const mode of ['read-only', 'workspace-write'] as const) {
|
||||
const target = join(worldWritableDir, `${mode}.txt`)
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target,
|
||||
])
|
||||
expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0)
|
||||
expect(existsSync(target), `mode: ${mode}`).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('partial boundary: a workspace hard link lets the grant reach an external file object', () => {
|
||||
// NTFS ACLs belong to the file object, not one pathname. Propagating the
|
||||
// workspace write-SID ACE through an existing hard-link alias therefore
|
||||
// grants the external alias too. pnpm workspaces commonly contain hard
|
||||
// links, so rejecting every multiply-linked file is not a viable profile.
|
||||
const hardlinkWorkspace = join(scratchRoot, 'hardlink-workspace')
|
||||
const hardlinkTemp = join(scratchRoot, 'hardlink-temp')
|
||||
const externalFile = join(scratchRoot, 'hardlink-target.txt')
|
||||
const workspaceLink = join(hardlinkWorkspace, 'hardlink-alias.txt')
|
||||
mkdirSync(hardlinkWorkspace)
|
||||
mkdirSync(hardlinkTemp)
|
||||
writeFileSync(externalFile, 'original')
|
||||
linkSync(externalFile, workspaceLink)
|
||||
const result = runRunner([
|
||||
// This workspace has not been granted before the alias exists: the first
|
||||
// recursive materialization reaches the shared file security descriptor.
|
||||
'--workspace', hardlinkWorkspace, '--temp', hardlinkTemp, '--mode', 'workspace-write',
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'mutated')", workspaceLink,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(readFileSync(externalFile, 'utf8')).toBe('mutated')
|
||||
}, 30_000)
|
||||
|
||||
it('runner-side failure: signature on stderr and exit 127, the command never runs', () => {
|
||||
const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write'])
|
||||
expect(result.status).toBe(127)
|
||||
|
||||
Reference in New Issue
Block a user