fix(sandbox): report Windows ACL limits

This commit is contained in:
Tianyi Cui
2026-08-10 14:59:38 +08:00
parent 6648ff984a
commit 9a3c89d04a
20 changed files with 124 additions and 70 deletions
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 后,必须重载插件才能改变选择。
+13 -11
View File
@@ -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: '] }])
})