fix(sandbox): reject overlapping Windows temp roots

This commit is contained in:
Tianyi Cui
2026-08-10 17:54:48 +08:00
parent 1762d2b3d7
commit 9435ca62a6
16 changed files with 167 additions and 16 deletions

View File

@@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win
## Decision
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor.
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor.
## How the restriction works (why no new identity)

View File

@@ -10,7 +10,7 @@ Status: implemented
## Decision
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken``WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closedPOC 因忽略失败而 fail-open。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID`tempWriteSid`);其 ACE 可回收TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID因此崩溃残留只是失效垃圾而非冲突或继承的能力无 agent智能体的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone]workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`原生套件则钉住这两个缺口。Read-only 不含任何能力 SID因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID禁用临时目录时继承工作区 SIDread-only 下继承 Everyonenamed pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken``WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closedPOC 因忽略失败而 fail-open。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID`tempWriteSid`);其 ACE 可回收TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID因此崩溃残留只是失效垃圾而非冲突或继承的能力无 agent智能体的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone]workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`原生套件则钉住这两个缺口。Read-only 不含任何能力 SID因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID禁用临时目录时继承工作区 SIDread-only 下继承 Everyonenamed pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。
## How the restriction works (why no new identity)

View File

@@ -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: eebb0df957f66585d8bbe35fee158776231f80d5
README.zh.md: d4a9cef722134b3ffab238b43b73d83e13d4ee6d
README.md: e43133c7c5b64d7779162b790ee6cab7806fd100
README.zh.md: 32743d1b0aba5bed41ee53d90c4ed44dc936161c

View File

@@ -12,7 +12,7 @@ Policy is per call; the provider stores only the mechanism and cached runner ver
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner.
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child.
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.

View File

@@ -12,7 +12,7 @@
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list因此恰好约束相应模式承诺的文件操作`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`。Apple 将 `sandbox-exec` CLI命令行界面标为 deprecated但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此共享工作区的会话会共享预期的写权限却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID因此崩溃残留既无法阻止恢复的会话也无法向其授权runner 会为无 agent智能体的调用提供同样的逐调用隔离。
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此共享工作区的会话会共享预期的写权限却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID因此崩溃残留既无法阻止恢复的会话也无法向其授权runner 会为无 agent智能体的调用提供同样的逐调用隔离。如果工作区等于或包含平台临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的工作区 ACE 会延伸到每个私有临时子目录。
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。

View File

@@ -37,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -390,6 +390,7 @@ export class LocalSandboxProvider extends SandboxProvider {
* @returns the pair's private temp directory and write capability.
*/
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
const writeSid = workspaceWriteSid(workspaceRoot)
if (!this.workspaceGrants.has(workspaceRoot)) {
const grant = AclWriteGrant.create(writeSid)

View File

@@ -5,7 +5,7 @@
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
*/
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,6 +51,13 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
}
return {
AclWriteGrant: MockAclWriteGrant,
assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => {
const workspace = realpathSync.native(workspaceRoot)
const temp = realpathSync.native(tempRoot)
if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) {
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
}
},
workspaceWriteSid: () => 'S-1-4-42-42',
tempWriteSid: (path: string) => `TEMP:${path}`,
}
@@ -238,6 +245,14 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
}
})
it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => {
const { sandbox } = await setup()
expect(() => sandbox.confine(['true'], {
mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'),
})).toThrow(/temp root must be outside the workspace/u)
expect(mockState.grants).toHaveLength(0)
})
it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => {
try {
const { sandbox } = await setup()

View File

@@ -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: 9134fa7b07699f4de8a264c8831aa2347f2d5e18
README.zh.md: 49b5ebbd792395c86e1031f4efc49e3042dbfe55
README.md: 280dc2b38844feff87eb792223b87ead251f3e16
README.zh.md: 06121c3142bd788d0e1fe8cfa38fa8a668bb270a

View File

@@ -50,7 +50,7 @@ node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write>
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 self-managed 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.
**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine.
**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. A workspace equal to or containing that root is rejected before any grant because its inheritable workspace ACE would otherwise authorize every private child; the direct API likewise rejects overlap between any writable root and the actual private temp directory. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine.
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, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries.
@@ -78,7 +78,7 @@ The koffi struct definitions assert their sizes against the probe at module load
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; 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.
- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation.
- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child.
- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The actual temp directory must be disjoint from every writable root. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child, but reject a workspace equal to or containing that parent before any ACL mutation.
- **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that two tokens sharing the same workspace SID cannot write one another's temp directories.
- **`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.

View File

@@ -52,7 +52,7 @@ node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write>
runner 创建受限令牌,在它之下 spawn 包装后的 argv调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` jobrunner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
**工作区复用与临时隔离**seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID因此崩溃残留只是失效垃圾而非冲突或继承的能力。如果不带这一对标志`--temp` 指定的是根目录:无 agent智能体/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID重写 TMP/TEMP并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。
**工作区复用与临时隔离**seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID因此崩溃残留只是失效垃圾而非冲突或继承的能力。如果不带这一对标志`--temp` 指定的是根目录:无 agent智能体/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID重写 TMP/TEMP并在退出时移除该子目录。工作区若等于或包含该根目录,会在任何授权前被拒绝,因为否则其可继承的工作区 ACE 会向每个私有子目录授权;直接 API 同样拒绝任何可写根目录与实际私有临时目录重叠。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
- `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。
@@ -80,7 +80,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
- **控制台隔离不可用。** 在受限令牌下,以 `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 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。
- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录并自行创建随机子目录。
- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。实际临时目录不得与任何可写根目录重叠。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录并自行创建随机子目录,但如果工作区等于或包含该父根目录,就会在任何 ACL 改动前拒绝调用
- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录子进程继承改写后的环境块bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。
- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。

View File

@@ -47,12 +47,14 @@ import { grantWrite, revokeWrite } from './acl.ts'
import { Win32Error } from './errors.ts'
import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts'
import type { NativePtr, Win32Bindings } from './ffi.ts'
import { assertPrivateTempDisjoint } from './path-boundary.ts'
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
import * as abi from './win32-abi.ts'
export { quoteArg } from './spawn.ts'
export { AclWriteGrant } from './grant.ts'
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { Win32Error } from './errors.ts'
@@ -242,6 +244,7 @@ export class AclSandbox {
if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) {
throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`)
}
assertPrivateTempDisjoint(this.writableDirs, tempDir)
}
this.tempDirResolved = tempDir

View File

@@ -0,0 +1,40 @@
/**
* Canonical directory-boundary checks for the Windows ACL workspace and
* private-temp capabilities.
* @module @deepseek-ai/dsh-sandbox-windows-acl/path-boundary
*/
import { realpathSync } from 'node:fs'
import { isAbsolute, relative, sep } from 'node:path'
/** Whether `root` is the same canonical directory as `candidate` or contains it. */
function containsDirectory(root: string, candidate: string): boolean {
const relation = relative(realpathSync.native(root), realpathSync.native(candidate))
return relation === '' || (!isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${sep}`))
}
/**
* Reject a temp parent that is inside the workspace: every child created
* below it would inherit the standing workspace capability.
* @param workspaceRoot - the canonical workspace root that receives the standing ACE.
* @param tempRoot - the existing parent beneath which a private temp child would be created.
*/
export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void {
if (containsDirectory(workspaceRoot, tempRoot)) {
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
}
}
/**
* Reject overlap between an actual private temp directory and any writable
* directory: either inheritance direction would merge the two capabilities.
* @param writableDirs - directories carrying the standing workspace capability.
* @param tempDir - the existing directory carrying the revocable temp capability.
*/
export function assertPrivateTempDisjoint(writableDirs: readonly string[], tempDir: string): void {
for (const writableDir of writableDirs) {
if (containsDirectory(writableDir, tempDir) || containsDirectory(tempDir, writableDir)) {
throw new Error(`AclSandbox private temp directory must be disjoint from writable directories: writable=${writableDir}; temp=${tempDir}`)
}
}
}

View File

@@ -48,7 +48,7 @@ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { win32 } from './ffi.ts'
import { AclSandbox } from './index.ts'
import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts'
import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
const RUNNER_SIGNATURE = 'windows-acl-run'
@@ -126,6 +126,9 @@ async function main(): Promise<number> {
if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
fail('workspace-write requires --write-sid and --temp-write-sid together')
}
if (parsed.mode === 'workspace-write') {
assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp)
}
const api = await win32()
// Ignore this process's own CTRL+C: the confined child (same console) keeps

View File

@@ -9,7 +9,7 @@
* whose per-test lock file is removed in cleanup.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -207,6 +207,26 @@ describe.skipIf(!isWin32)('ACL editing', () => {
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false)
})
it('rejects an overlapping private temp directory before applying either capability', async () => {
const workspaceDir = scratch()
const nestedTemp = join(workspaceDir, 'temp')
const writeSid = 'S-1-4-9000-30'
const privateTempSid = 'S-1-4-9000-30-1'
mkdirSync(nestedTemp)
const sandbox = new AclSandbox({
writableDirs: [workspaceDir],
tempDir: nestedTemp,
writeSid,
tempWriteSid: privateTempSid,
mode: 'workspace-write',
})
await expect(sandbox.init()).rejects.toThrow(/private temp directory must be disjoint/u)
const api = await win32()
expect(readDirectAces(api, workspaceDir).some(ace => ace.sid === writeSid)).toBe(false)
expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false)
})
it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
const dir = scratch()
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))

View File

@@ -0,0 +1,53 @@
/** Canonical path-overlap checks that keep workspace and temp capabilities separate. */
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from '../src/path-boundary.ts'
describe('Windows ACL temp path boundary', () => {
const scratchDirs: string[] = []
afterEach(() => {
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-boundary-'))
scratchDirs.push(dir)
return dir
}
it('rejects a temp root equal to or below the workspace', () => {
const workspace = scratch()
const nested = join(workspace, 'temp')
mkdirSync(nested)
expect(() => assertTempRootOutsideWorkspace(workspace, workspace)).toThrow(/temp root must be outside the workspace/u)
expect(() => assertTempRootOutsideWorkspace(workspace, nested)).toThrow(/temp root must be outside the workspace/u)
})
it('accepts a temp parent above the workspace because a fresh child is a sibling', () => {
const tempRoot = scratch()
const workspace = join(tempRoot, 'workspace')
mkdirSync(workspace)
expect(() => assertTempRootOutsideWorkspace(workspace, tempRoot)).not.toThrow()
})
it('requires an actual private temp directory to be disjoint in either direction', () => {
const root = scratch()
const workspace = join(root, 'workspace')
const nestedTemp = join(workspace, 'temp')
const siblingTemp = join(root, 'sibling-temp')
mkdirSync(workspace)
mkdirSync(nestedTemp)
mkdirSync(siblingTemp)
expect(() => assertPrivateTempDisjoint([workspace], nestedTemp)).toThrow(/must be disjoint/u)
expect(() => assertPrivateTempDisjoint([nestedTemp], workspace)).toThrow(/must be disjoint/u)
expect(() => assertPrivateTempDisjoint([workspace], siblingTemp)).not.toThrow()
})
})

View File

@@ -277,6 +277,22 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(tempB)).toBe(false)
}, 30_000)
it('agentless workspace-write rejects a temp root inside the workspace before spawning', () => {
const overlapWorkspace = join(scratchRoot, 'overlap-workspace')
const nestedTempRoot = join(overlapWorkspace, 'temp')
const marker = join(overlapWorkspace, 'command-ran.txt')
mkdirSync(overlapWorkspace)
mkdirSync(nestedTempRoot)
const result = runRunner([
'--workspace', overlapWorkspace, '--temp', nestedTempRoot, '--mode', 'workspace-write',
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran')", marker,
])
expect(result.status, `stderr: ${result.stderr}`).toBe(127)
expect(result.stderr).toContain('windows-acl-run: Windows ACL temp root must be outside the workspace')
expect(existsSync(marker)).toBe(false)
}, 15_000)
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
// Two-layer pin of the grandchild-spawn boundary:
// - the token default DACL carries a restricting-SID ACE (set in init),