fix(sandbox): reject overlapping Windows temp roots
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: eebb0df957f66585d8bbe35fee158776231f80d5
|
||||
README.zh.md: d4a9cef722134b3ffab238b43b73d83e13d4ee6d
|
||||
README.md: e43133c7c5b64d7779162b790ee6cab7806fd100
|
||||
README.zh.md: 32743d1b0aba5bed41ee53d90c4ed44dc936161c
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 中,可防止约定漂移。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` job(runner 死亡则子进程死亡),忽略自身的控制台 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` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
40
packages/sandbox/sandbox-windows-acl/src/path-boundary.ts
Normal file
40
packages/sandbox/sandbox-windows-acl/src/path-boundary.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user