@qilin-agent/sandbox-local 0.0.0-stage → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
5
+ /:
6
+ en: f172767891b86bf4
7
+ zh: e66e1cc8c96cc259
8
+ /deepseek-ai-dsh-sandbox-local:
9
+ en: a8904c33495ab07b
10
+ zh: 51e7ea32db36a60d
11
+ /deepseek-ai-dsh-sandbox-local/summary:
12
+ en: 18ed12f82c8a89d5
13
+ zh: f950357a81c90e0c
14
+ /deepseek-ai-dsh-sandbox-local/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /deepseek-ai-dsh-sandbox-local/use-this-package:
18
+ en: 3759ad594d0a27fb
19
+ zh: 722010d952b9f87f
20
+ /deepseek-ai-dsh-sandbox-local/use-this-package/when-to-choose-it:
21
+ en: 722ad601000b13c7
22
+ zh: d2723f3743fe160b
23
+ /deepseek-ai-dsh-sandbox-local/use-this-package/minimal-configuration:
24
+ en: 517ebfe10f845f03
25
+ zh: dd5ece48cf66a3a8
26
+ /deepseek-ai-dsh-sandbox-local/use-this-package/confined-execution-and-enforcement:
27
+ en: 7282c9ec77fd6f01
28
+ zh: 10e385a9489105e3
29
+ /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery:
30
+ en: 3808edb37dde5761
31
+ zh: a8fef8a4fd33e364
32
+ /deepseek-ai-dsh-sandbox-local/understand-the-implementation:
33
+ en: 15db6ec303151ccc
34
+ zh: 91f62c1f64192f92
35
+ /deepseek-ai-dsh-sandbox-local/understand-the-implementation/runner-selection:
36
+ en: f0c44248c2ca6acc
37
+ zh: 554e9f706671d222
38
+ /deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles:
39
+ en: 7a16c5fed538e5a4
40
+ zh: 61bce56e14e312b2
41
+ /deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects:
42
+ en: 62adfc9229c656cc
43
+ zh: 11b1534b6d61e5de
44
+ /deepseek-ai-dsh-sandbox-local/understand-the-implementation/source-map:
45
+ en: 215bb6bac514b8b4
46
+ zh: 41e7777eea10a480
47
+ /deepseek-ai-dsh-sandbox-local/further-exploration:
48
+ en: 187110897f465b7a
49
+ zh: cc201c9f208579d4
50
+ /deepseek-ai-dsh-sandbox-local/model-experience:
51
+ en: 6bd2ede8112cdf64
52
+ zh: 471c33f5aab03bdb
53
+ /deepseek-ai-dsh-sandbox-local/model-experience/kv-cache-effect:
54
+ en: e290ccc9b3c92c08
55
+ zh: 469589ac12cdadab
56
+ /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work:
57
+ en: b2a6a72e340bc620
58
+ zh: 9d9b2894850a131d
59
+ /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note:
60
+ en: cc273320f6a1beaf
61
+ zh: 5c57a312ddc50651
62
+ /deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note/future-environment-coherent-groups:
63
+ en: 75f4c2b779da70f9
64
+ zh: d00996cfb3ce41d9
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Local per-platform sandbox backends for users and maintainers choosing, configuring, or debugging process confinement on Linux, macOS, or Windows."
3
+ kind: "package-reference"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @deepseek-ai/dsh-sandbox-local
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ `@qilin-agent/sandbox-local` confines commands and their descendants on Linux, macOS, and Windows while sharing the host kernel and filesystem. It chooses a supported platform runner automatically and fails with `SANDBOX_UNAVAILABLE` when none is usable, so commands never silently run without confinement. Each execution reports `full` or `partial` enforcement plus denial and runner-failure signatures, allowing callers to distinguish an unavailable or broken sandbox from a policy denial. Choose it for host-local bash or pwsh execution; use a container or remote executor when the process needs an isolated environment.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount this provider behind `ctx.sandbox` and a confined executor, and every command the executor spawns runs confined under the policy you resolve. The shipped [base bundle](../../bundle/base/cordis.patch.yml) owns the default policy and executor wiring.
29
+
30
+ ### When to choose it
31
+
32
+ Choose it when commands must run confined on the host: it is the default backend for Linux, macOS, and Windows compositions that mount `ctx.sandbox`. Choose a different mechanism when the process must run in an isolated environment — a container or remote executor replaces whole capabilities, and this provider shares the host kernel and filesystem.
33
+
34
+ ### Minimal configuration
35
+
36
+ Load the sandbox service and mount the provider; the defaults below are the selection policy.
37
+
38
+ ```yaml
39
+ - id: sandbox
40
+ name: '@deepseek-ai/dsh-sandbox-local'
41
+ ```
42
+
43
+ | Field | Default | Meaning |
44
+ |---|---|---|
45
+ | `runnerCommand` | `[]` | Custom runner argv; bwrap-compatible profile arguments are appended, full enforcement is asserted, and built-in selection and probes are skipped |
46
+ | `runnerFailureSignatures` | `[]` | Case-insensitive stderr substrings identifying the custom runner's own failure dialect; required with `runnerCommand` |
47
+ | `probeTimeoutMs` | `5,000` | Timeout for each functional probe of a competing runner candidate |
48
+
49
+ The generated [configuration catalog](../../../docs/config-catalog.md#qilin-agentsandbox-local) is the exhaustive source for every accepted field and its JSDoc.
50
+
51
+ ### Confined execution and enforcement
52
+
53
+ With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (hard-link, unconfined-read, and AppContainer-ACL boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature.
54
+
55
+ ### Failures and recovery
56
+
57
+ An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
58
+
59
+ -----
60
+
61
+ <a id="understand-the-implementation"></a>
62
+ ## Understand the implementation
63
+
64
+ <details>
65
+ <summary>Implementation internals — click to expand</summary>
66
+
67
+ This section explains runner selection, the per-platform profiles, and the failure dialects; the observable behavior is fully covered in [Use this package](#use-this-package).
68
+
69
+ ### Runner selection
70
+
71
+ Selection is by platform first, probes second: each platform has a runner chain (`linux`: `bwrap` then Landlock; `darwin`: Seatbelt; `win32`: the ACL restricted-token runner). A sole candidate is selected without a probe; competing candidates are functionally probed once in chain order, and the first usable verdict is cached for the provider's lifetime. A platform with no chain, or a chain where every probe fails, is unavailable and fails closed at `confine()`.
72
+
73
+ ### Platform profiles
74
+
75
+ The bwrap profile combines a read-only host root, a fresh `/dev`, and `/proc` from a private PID namespace — commands manage their descendants but cannot see host processes, so procfs magic links cannot bypass the mounts; `workspace-write` adds an ephemeral `/tmp` and a writable workspace bind. The [private-PID note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.md) records the boundary.
76
+
77
+ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform launcher, functional probe, and grant vocabulary; this provider maps mode to grants only, keeping path resolution and probe parsing with the versioned binary.
78
+
79
+ The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`).
80
+
81
+ The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace 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. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
82
+
83
+ With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources.
84
+
85
+ When the built ACL runner is absent, source launch pins the `tsx/esm/api` loader and TypeScript path mapping to this installation. The command's working directory and ambient `TSX_TSCONFIG_PATH` cannot select the runner's source dependencies.
86
+
87
+ ### Denial and runner-failure dialects
88
+
89
+ Each runner's kernel speaks its own denial dialect, carried on every wrap as `denialSignatures`, and `runnerFailureRules` give each runner's fatal signature, so consumers classify a runner refusal before checking denial signatures. The exact strings and exit codes live in [`src/index.ts`](src/index.ts).
90
+
91
+ ### Source map
92
+
93
+ | File | Role |
94
+ |---|---|
95
+ | [`src/index.ts`](src/index.ts) | Plugin entry: runner chain selection, functional probes, per-call wrap, ACL grant lifecycle |
96
+ | [`src/profiles.ts`](src/profiles.ts) | Per-platform profile builders: bwrap mounts, Landlock grants, Seatbelt SBPL |
97
+
98
+ </details>
99
+
100
+ -----
101
+
102
+ <a id="further-exploration"></a>
103
+ ## Further Exploration
104
+
105
+ Start with the subsystem reference for the shared vocabulary, then the seam contract, the consumers, and the win32 rung.
106
+
107
+ - [Process sandbox subsystem](../../../docs/subsystems/sandbox.md) — modes, per-call policy, and classification dialects.
108
+ - [Sandbox seam package](../sandbox/README.md) — the service contract this provider implements.
109
+ - [Bash sandbox executor](../../shell/bash-sandbox/README.md) — the confined bash consumer.
110
+ - [Windows ACL restricted-token rung](../sandbox-windows-acl/README.md) — the win32 backend this provider mounts.
111
+ - [The subprocess sandbox decision](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) — capability boundary and runner selection semantics.
112
+
113
+ -----
114
+
115
+ <a id="model-experience"></a>
116
+ ## Model Experience
117
+
118
+ Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md) and [`dsh-tool-bash`](../../shell/tool-bash/README.md), which render this provider's enforcement and denial facts, while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and this provider owns runner selection, and profiles stay outside context.
119
+
120
+ #### KV Cache effect
121
+
122
+ No direct invalidation; the named consumers own any request-prefix changes.
123
+
124
+ ## Known Limitations and Deferred Work
125
+
126
+ <a id="known-limitations-and-deferred-work"></a>
127
+
128
+
129
+ These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
130
+
131
+ - **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
132
+ - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
133
+ - **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.
134
+ - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
135
+ - **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-compatible profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement.
136
+
137
+ <a id="dev-note"></a>
138
+ ### Dev Note
139
+
140
+ <details>
141
+ <summary>Working context for maintainers — click to expand</summary>
142
+
143
+ This Dev Note is working context for maintainers: undecided directions and open questions. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
144
+
145
+ #### Future: environment-coherent groups
146
+
147
+ The [sandbox decision](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) lists an environment-coherent capability group example (for example bash plus fs against one container) as a deferred phase; it is not decided.
148
+
149
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,149 @@
1
+ ---
2
+ description: "面向 Linux、macOS 或 Windows 上选择、配置或排查进程隔离的用户与维护者的本地各平台沙箱后端。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-sandbox-local
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ `@qilin-agent/sandbox-local` 在共享宿主内核和文件系统的同时,限制 Linux、macOS 与 Windows 上的命令及其派生进程。它自动选择受支持的平台 runner;没有可用 runner 时以 `SANDBOX_UNAVAILABLE` 失败,因此命令绝不会静默无限制运行。每次执行都会报告 `full` 或 `partial` 强制执行,以及拒绝和 runner 失败签名,让调用方能区分不可用或损坏的沙箱与策略拒绝。宿主本地 bash 或 pwsh 执行适合选择它;进程需要隔离环境时应改用容器或远程执行器。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 在 `ctx.sandbox` 后挂载此提供方并配一个受限执行器,执行器 spawn 的每条命令都会在你解析的策略下受限运行。随附的[基础组合包](../../bundle/base/cordis.patch.yml)拥有默认策略与执行器接线。
29
+
30
+ ### 何时选择
31
+
32
+ 当命令必须在宿主机上受限运行时选择它:它是挂载 `ctx.sandbox` 的 Linux、macOS 与 Windows 组合的默认后端。当进程必须在隔离环境中运行时请另选机制——容器或远程执行器会替换整个能力,而此提供方与宿主共享内核和文件系统。
33
+
34
+ ### 最小配置
35
+
36
+ 加载沙箱服务并挂载提供方;以下默认值即选择策略。
37
+
38
+ ```yaml
39
+ - id: sandbox
40
+ name: '@deepseek-ai/dsh-sandbox-local'
41
+ ```
42
+
43
+ | 字段 | 默认值 | 含义 |
44
+ |---|---|---|
45
+ | `runnerCommand` | `[]` | 自定义 runner argv;会追加 bwrap 兼容的 profile 参数,断言完全强制执行,并跳过内置选择与探测 |
46
+ | `runnerFailureSignatures` | `[]` | 识别自定义 runner 自身失败方言的不区分大小写 stderr 子串;与 `runnerCommand` 搭配必需 |
47
+ | `probeTimeoutMs` | `5,000` | 每次竞争 runner 候选功能探测的超时时间 |
48
+
49
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#qilin-agentsandbox-local)是每个受支持字段及其 JSDoc 的穷尽式真源。
50
+
51
+ ### 受限执行与强制执行
52
+
53
+ 挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(硬链接、读取不受限与 AppContainer ACL 边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。
54
+
55
+ ### 失败与恢复
56
+
57
+ 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
58
+
59
+ -----
60
+
61
+ <a id="understand-the-implementation"></a>
62
+ ## 理解实现
63
+
64
+ <details>
65
+ <summary>实现细节——点击展开</summary>
66
+
67
+ 本节解释 runner 选择、各平台 profile 与失败方言;可观察行为已在[使用本包](#use-this-package)中完整说明。
68
+
69
+ ### runner 选择
70
+
71
+ 选择按平台优先、探测其次:每个平台都有 runner 链(`linux`:`bwrap` 再 Landlock;`darwin`:Seatbelt;`win32`:ACL 受限令牌 runner)。唯一候选直接选择、不探测;竞争候选按链序各执行一次功能探测,首个可用结论在提供方生命周期内缓存。没有链的平台、或链上所有探测都失败时,平台不可用,`confine()` 会拒绝执行。
72
+
73
+ ### 平台 profile
74
+
75
+ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名空间中的 `/proc`——命令可管理其后代,但看不到宿主进程,因此 procfs 魔法链接无法绕过挂载;`workspace-write` 另加临时的 `/tmp` 与可写工作区绑定挂载。[私有 PID 笔记](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md)记录该边界。
76
+
77
+ `@deepseek-ai/node-addon-system/landlock-run` API 提供平台 launcher、功能探测与授权词汇;此提供方只做模式到授权的映射,把路径解析与探测解析保留在带版本的 binary 中。
78
+
79
+ Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。
80
+
81
+ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
82
+
83
+ 当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。
84
+
85
+ 构建后的 ACL runner 缺失时,源码启动将 `tsx/esm/api` 加载器和 TypeScript 路径映射固定到本安装目录。命令的工作目录和环境中的 `TSX_TSCONFIG_PATH` 无法选择 runner 的源码依赖。
86
+
87
+ ### 拒绝与 runner 失败方言
88
+
89
+ 每个 runner 的内核都有自己的拒绝方言,随每次包装以 `denialSignatures` 携带,`runnerFailureRules` 则给出每个 runner 的致命签名,因此消费方先分类 runner 拒绝,再检查拒绝签名。精确的字符串与退出码位于 [`src/index.ts`](src/index.ts)。
90
+
91
+ ### 源码地图
92
+
93
+ | 文件 | 职责 |
94
+ |---|---|
95
+ | [`src/index.ts`](src/index.ts) | 插件入口:runner 链选择、功能探测、逐调用包装、ACL 授权生命周期 |
96
+ | [`src/profiles.ts`](src/profiles.ts) | 各平台 profile 构建器:bwrap 挂载、Landlock 授权、Seatbelt SBPL |
97
+
98
+ </details>
99
+
100
+ -----
101
+
102
+ <a id="further-exploration"></a>
103
+ ## 进一步探索
104
+
105
+ 先从子系统参考文档了解共享词汇,再看 seam 约定、消费方与 win32 档。
106
+
107
+ - [进程沙箱子系统](../../../docs/subsystems/sandbox.zh.md)——模式、逐调用策略与分类方言。
108
+ - [沙箱 seam 包](../sandbox/README.zh.md)——本提供方实现的服务约定。
109
+ - [Bash 沙箱执行器](../../shell/bash-sandbox/README.zh.md)——受限的 bash 消费方。
110
+ - [Windows ACL 受限令牌档](../sandbox-windows-acl/README.zh.md)——本提供方挂载的 win32 后端。
111
+ - [子进程沙箱决策](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)——能力边界与 runner 选择语义。
112
+
113
+ -----
114
+
115
+ <a id="model-experience"></a>
116
+ ## 模型体验
117
+
118
+ 通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md) 和 [`dsh-tool-bash`](../../shell/tool-bash/README.zh.md) 间接影响;它们渲染此提供方的强制执行与拒绝事实,而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本、本提供方拥有 runner 选择,profile 不进入上下文。
119
+
120
+ #### KV Cache 影响
121
+
122
+ 不会直接使 KV Cache 失效;请求前缀变更由上述消费方负责。
123
+
124
+ ## 已知限制与延期工作
125
+
126
+ <a id="known-limitations-and-deferred-work"></a>
127
+
128
+
129
+ 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。
130
+
131
+ - **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
132
+ - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
133
+ - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
134
+ - **runner 选择在提供方生命周期内缓存**——安装、移除或修复 runner 后,必须重载插件才能改变选择。
135
+ - **`runnerCommand` 是操作方断言**——配置的自定义 runner 会跳过功能探测,并假定它诚实实现与 bwrap 兼容的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。
136
+
137
+ <a id="dev-note"></a>
138
+ ### 开发备注
139
+
140
+ <details>
141
+ <summary>维护者的工作上下文——点击展开</summary>
142
+
143
+ 本开发备注是维护者的工作上下文:未决方向与开放问题。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关 Agent Note 为准。
144
+
145
+ #### 未来:环境一致的能力组
146
+
147
+ [沙箱决策](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)把环境一致的能力组示例(例如 bash 加 fs 针对同一个容器)列为延期阶段;该方向尚未决定。
148
+
149
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,555 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, grantArgs, launcherPath, probe } from "@qilin-agent/node-addon-system/landlock-run";
7
+ import z from "@qilin-agent/schemastery";
8
+ import { SandboxProvider, SandboxUnavailableError, canonicalPath, writableRoots } from "@qilin-agent/sandbox";
9
+ import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from "@qilin-agent/sandbox-windows-acl";
10
+ import { assertNever } from "@qilin-agent/util-values";
11
+ //#region lib/types/profiles.js
12
+ /**
13
+ * Internal platform-profile builders for the local sandbox provider.
14
+ *
15
+ * @module @qilin-agent/sandbox-local/profiles
16
+ */
17
+ /**
18
+ * Build the bwrap profile arguments for one file-effect policy.
19
+ * @param policy - file-effect policy to express as bwrap mounts.
20
+ * @returns profile arguments before the trailing separator and command argv.
21
+ */
22
+ function bwrapProfileArgs(policy) {
23
+ const args = [
24
+ "--ro-bind",
25
+ "/",
26
+ "/",
27
+ "--dev",
28
+ "/dev",
29
+ "--unshare-pid",
30
+ "--proc",
31
+ "/proc",
32
+ "--die-with-parent"
33
+ ];
34
+ if (policy.mode === "workspace-write") {
35
+ args.push("--tmpfs", "/tmp");
36
+ args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
37
+ }
38
+ return args;
39
+ }
40
+ /**
41
+ * Build the Landlock launcher grants for one file-effect policy.
42
+ * @param policy - file-effect policy to express as Landlock allow-list grants.
43
+ * @returns launcher grant arguments before the trailing separator and command argv.
44
+ */
45
+ function landlockProfileArgs(policy) {
46
+ const readWrite = ["/dev/null"];
47
+ if (policy.mode === "workspace-write") readWrite.push("/tmp", policy.workspaceRoot);
48
+ return grantArgs({
49
+ readOnly: ["/"],
50
+ readWrite
51
+ });
52
+ }
53
+ /** Quote one path as an SBPL string literal. */
54
+ function sbplString(path) {
55
+ return `"${path.replaceAll("\\", String.raw`\\`).replaceAll("\"", String.raw`\"`)}"`;
56
+ }
57
+ /**
58
+ * Build the sandbox-exec arguments and SBPL profile for one policy. The
59
+ * writable roots come from the shared {@link writableRoots} helper (canonical,
60
+ * deduplicated) so the Seatbelt grant and the in-process fs fence
61
+ * (`@qilin-agent/fs-sandbox`) can never drift apart.
62
+ * @param policy - file-effect policy to express as an SBPL profile.
63
+ * @returns sandbox-exec arguments before the trailing separator and command argv.
64
+ */
65
+ function seatbeltProfileArgs(policy) {
66
+ const forms = [
67
+ "(version 1)",
68
+ "(allow default)",
69
+ "(deny file-write*)",
70
+ `(allow file-write* (literal ${sbplString("/dev/null")}))`
71
+ ];
72
+ const roots = writableRoots(policy);
73
+ if (roots.length > 0) forms.push(`(allow file-write* ${roots.map((root) => `(subpath ${sbplString(root)})`).join(" ")})`);
74
+ return ["-p", forms.join(" ")];
75
+ }
76
+ //#endregion
77
+ //#region lib/types/index.js
78
+ /**
79
+ * Local sandbox backend. It selects the platform runner chain (Linux bwrap then
80
+ * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes
81
+ * competing candidates once, and reports each wrap's enforcement and stderr
82
+ * classification facts. Missing or unusable confinement fails closed rather
83
+ * than returning the original argv.
84
+ *
85
+ * The windows-acl rung additionally owns the write grants: the write SID is
86
+ * the per-WORKSPACE identity derived from the canonical workspace path
87
+ * (`workspaceWriteSid`), while every live session receives a RANDOM private
88
+ * temp directory and its own derived capability (`tempWriteSid`). The
89
+ * workspace-root ACE materializes once per workspace per server lifetime
90
+ * and STANDS (the cross-session reuse cache — the exact-ACE skip makes
91
+ * every later provision O(1) instead of re-propagating the tree per
92
+ * session); the private-temp ACEs are revoked on dispose. The runner
93
+ * receives both SIDs (their presence marks the seam-managed contract) and
94
+ * stops managing DACLs itself. The rung reports partial enforcement because
95
+ * WRITE_RESTRICTED must retain Everyone in its
96
+ * restricting list and NTFS hard links alias one file object across paths.
97
+ * @module @qilin-agent/sandbox-local
98
+ */
99
+ /** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
100
+ function defaultProbeBwrap(timeoutMs) {
101
+ return spawnSync("bwrap", [
102
+ ...bwrapProfileArgs({
103
+ mode: "read-only",
104
+ workspaceRoot: "/"
105
+ }),
106
+ "--",
107
+ "true"
108
+ ], {
109
+ timeout: timeoutMs,
110
+ stdio: "ignore"
111
+ }).status === 0;
112
+ }
113
+ /**
114
+ * Functional Seatbelt probe: apply the real `read-only` profile through
115
+ * `sandbox-exec -p` and run `true` under it — exit 0 means the kernel
116
+ * accepted and enforced the profile (`sandbox-exec` exits non-zero when
117
+ * `sandbox_init` refuses it). A missing `sandbox-exec` (every non-macOS
118
+ * host) fails the spawn and probes `unusable`, exactly like the other
119
+ * rungs' absent binaries. Apple marks the CLI deprecated but ships it on
120
+ * every macOS; if it ever disappears, this probe is what fails closed.
121
+ */
122
+ function defaultProbeSeatbelt(seatbeltExec, timeoutMs) {
123
+ return spawnSync(seatbeltExec, [
124
+ ...seatbeltProfileArgs({
125
+ mode: "read-only",
126
+ workspaceRoot: "/"
127
+ }),
128
+ "--",
129
+ "true"
130
+ ], {
131
+ timeout: timeoutMs,
132
+ stdio: "ignore"
133
+ }).status === 0;
134
+ }
135
+ /**
136
+ * Functional windows-acl probe: run the runner in read-only mode (zero grants,
137
+ * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created
138
+ * the restricted token and spawned the child under it. The win32 chain is a
139
+ * sole candidate, so the product never probes; the probe exists for override
140
+ * chains and mirrors the other rungs' shape.
141
+ */
142
+ function defaultProbeWindowsAcl(runnerInvocation, timeoutMs) {
143
+ const program = runnerInvocation[0];
144
+ if (program === void 0) return false;
145
+ return spawnSync(program, [
146
+ ...runnerInvocation.slice(1),
147
+ "--workspace",
148
+ tmpdir(),
149
+ "--temp",
150
+ tmpdir(),
151
+ "--mode",
152
+ "read-only",
153
+ "--",
154
+ "cmd",
155
+ "/c",
156
+ "exit",
157
+ "0"
158
+ ], {
159
+ timeout: timeoutMs,
160
+ stdio: "ignore"
161
+ }).status === 0;
162
+ }
163
+ /**
164
+ * The runner chain per platform — selection is BY PLATFORM first, probes
165
+ * second: a platform's chain is probed in preference order only when it has
166
+ * MORE than one candidate (probing arbitrates; it does not re-validate a
167
+ * choice that has no alternative). A platform with no chain fails closed at
168
+ * `confine()`. Linux prefers `bwrap` (its mount profile is closest to the
169
+ * mode vocabulary) over the Landlock launcher; darwin has exactly one
170
+ * candidate, selected without any probe.
171
+ */
172
+ const PLATFORM_CHAINS = {
173
+ linux: ["bwrap", "landlock"],
174
+ darwin: ["seatbelt"],
175
+ win32: ["windows-acl"]
176
+ };
177
+ /**
178
+ * Enforcement completeness a rung claims when selected WITHOUT a probe (a
179
+ * chain of one). `bwrap` and Seatbelt govern every promised file effect by
180
+ * construction, so the claim is a profile fact; `landlock` is listed for the
181
+ * table's totality but is unreachable without a probe (the Linux chain has
182
+ * two rungs, so it is only ever selected through its probe, whose report is
183
+ * what distinguishes full from per-ABI-partial — and the launcher additionally
184
+ * self-reports partial enforcement on stderr at every confined run).
185
+ */
186
+ const STATIC_ENFORCEMENT = {
187
+ bwrap: "full",
188
+ landlock: "full",
189
+ seatbelt: "full",
190
+ "windows-acl": "partial"
191
+ };
192
+ /**
193
+ * A probe bound must be a positive finite number: Node treats
194
+ * `spawnSync({ timeout: 0 })` as NO timeout, so an unvalidated 0 would
195
+ * silently mean "unbounded" — the opposite of what the field promises.
196
+ */
197
+ function assertPositiveFinite(name, value) {
198
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`sandbox-local: ${name} must be a positive finite number`);
199
+ }
200
+ /**
201
+ * The denial dialect each runner's kernel speaks — the case-insensitive stderr substrings a
202
+ * denied file effect produces under it, carried on every wrap (the seam's
203
+ * `ConfinedArgv.denialSignatures`).
204
+ */
205
+ const DENIAL_SIGNATURES = {
206
+ bwrap: ["read-only file system"],
207
+ landlock: ["permission denied"],
208
+ seatbelt: ["operation not permitted"],
209
+ "windows-acl": [
210
+ "access is denied",
211
+ "access to the path",
212
+ "permission denied",
213
+ "operation not permitted"
214
+ ],
215
+ runnerCommand: ["read-only file system", "permission denied"]
216
+ };
217
+ /**
218
+ * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
219
+ * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
220
+ * 1 but its public contract does not reserve that status, while sandbox-exec
221
+ * publishes no launcher-failure status; those backends remain signature-only.
222
+ * The windows-acl runner prints `windows-acl-run: <detail>` on every
223
+ * runner-side failure and exits 127 — the rule is exit-gated on that status
224
+ * so a confined command that merely PRINTS the signature (or a runner
225
+ * cleanup failure reported on a non-zero child exit) is never misclassified
226
+ * as "the command did not run". Keep the Landlock tuple aligned with the
227
+ * assembled snapshot fixture at
228
+ * `packages/test-support/session-snapshot/tests/fixtures/partial-landlock-sandbox.ts`.
229
+ */
230
+ const RUNNER_FAILURE_RULES = {
231
+ bwrap: [{ fatalSignatures: ["bwrap: "] }],
232
+ landlock: [{
233
+ allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
234
+ fatalSignatures: [`${LAUNCHER_BIN}: `],
235
+ informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`]
236
+ }],
237
+ seatbelt: [{ fatalSignatures: ["sandbox-exec: "] }],
238
+ "windows-acl": [{
239
+ allowedExitCodes: [127],
240
+ fatalSignatures: ["windows-acl-run: "]
241
+ }]
242
+ };
243
+ /**
244
+ * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
245
+ * chain verdict and, on the windows-acl rung, the write grants
246
+ * ({@link AclWriteGrant}: the standing workspace-root grant per workspace
247
+ * and the revocable private-temp grant per live session/workspace pair, the
248
+ * latter revoked on provider dispose); the one-time probes spawn nothing
249
+ * else.
250
+ */
251
+ var LocalSandboxProvider = class extends SandboxProvider {
252
+ static Config = z.object({
253
+ runnerCommand: z.array(z.string()).default([]),
254
+ runnerFailureSignatures: z.array(z.string()).default([]),
255
+ probeTimeoutMs: z.natural().default(5e3)
256
+ });
257
+ /** Test hook (mirrors the bash executors' `internals`). */
258
+ internals = {};
259
+ runnerCommand;
260
+ configuredRunnerFailureSignatures;
261
+ probeTimeoutMs;
262
+ /** Cached chain verdict; undefined until the first confined wrap needs it. */
263
+ selectedRunner;
264
+ /**
265
+ * Server-lifetime write grants (windows-acl rung): the STANDING
266
+ * workspace-root grant per workspace (its ACE is the cross-session reuse
267
+ * cache and outlives the provider — never revoked) and the REVOCABLE
268
+ * private-temp grant per live session/workspace pair (revoked on provider
269
+ * dispose).
270
+ */
271
+ workspaceGrants = /* @__PURE__ */ new Map();
272
+ tempCapabilities = /* @__PURE__ */ new Map();
273
+ constructor(ctx, config) {
274
+ super(ctx);
275
+ const runner = config.runnerCommand;
276
+ const runnerFailureSignatures = config.runnerFailureSignatures;
277
+ if (runner.length === 0 && runnerFailureSignatures.length > 0) throw new Error("sandbox-local: runnerFailureSignatures requires runnerCommand");
278
+ if (runner.length > 0 && runnerFailureSignatures.length === 0) throw new Error("sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry");
279
+ if (runnerFailureSignatures.some((signature) => signature.trim().length === 0 || /[\r\n]/u.test(signature))) throw new Error("sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings");
280
+ this.runnerCommand = runner.length > 0 ? runner : void 0;
281
+ this.configuredRunnerFailureSignatures = runnerFailureSignatures;
282
+ this.probeTimeoutMs = config.probeTimeoutMs;
283
+ assertPositiveFinite("probeTimeoutMs", this.probeTimeoutMs);
284
+ /* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */
285
+ if (process.platform === "win32" && this.runnerCommand === void 0) ctx.inject(["skills"], (skillsCtx) => {
286
+ registerAclDiagnosisSkill(skillsCtx);
287
+ });
288
+ ctx.effect(() => () => {
289
+ this.revokeAclGrants();
290
+ });
291
+ }
292
+ /**
293
+ * Wrap `argv` in the selected runner's invocation for `policy` — the configured
294
+ * `runnerCommand` when present (the operator's assertion, no probe), else the platform
295
+ * chain's runner speaking its own profile dialect.
296
+ *
297
+ * @param argv - the exact argv the caller is about to spawn.
298
+ * @param policy - the file-effect policy this execution runs under.
299
+ * @param signal - cancellation before policy resolution or grant creation.
300
+ * @returns the wrapped argv plus the selected backend's enforcement completeness, denial
301
+ * signatures, and structured runner-failure rules; throws the fail-closed
302
+ * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
303
+ */
304
+ async confine(argv, policy, signal) {
305
+ signal?.throwIfAborted();
306
+ policy = {
307
+ ...policy,
308
+ workspaceRoot: canonicalPath(policy.workspaceRoot)
309
+ };
310
+ if (this.runnerCommand !== void 0) return Promise.resolve({
311
+ argv: [
312
+ ...this.runnerCommand,
313
+ ...bwrapProfileArgs(policy),
314
+ "--",
315
+ ...argv
316
+ ],
317
+ enforcement: "full",
318
+ denialSignatures: DENIAL_SIGNATURES.runnerCommand,
319
+ runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }]
320
+ });
321
+ const selected = this.selectRunner(policy.mode);
322
+ const runnerArgv = this.runnerArgv(selected.runner, policy);
323
+ return Promise.resolve({
324
+ argv: [
325
+ ...runnerArgv,
326
+ "--",
327
+ ...argv
328
+ ],
329
+ enforcement: selected.enforcement,
330
+ denialSignatures: DENIAL_SIGNATURES[selected.runner],
331
+ runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner]
332
+ });
333
+ }
334
+ /** The selected rung's runner invocation (program + profile arguments) for one policy. */
335
+ runnerArgv(runner, policy) {
336
+ switch (runner) {
337
+ case "bwrap": return ["bwrap", ...bwrapProfileArgs(policy)];
338
+ case "landlock": return [this.landlockLauncher(), ...landlockProfileArgs(policy)];
339
+ case "seatbelt": return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)];
340
+ case "windows-acl": return this.windowsAclRunnerArgv(policy);
341
+ default: return assertNever(runner);
342
+ }
343
+ }
344
+ /**
345
+ * The windows-acl runner argv for one policy. With a calling session (the
346
+ * policy's `sessionId`) under workspace-write, the grants are materialized
347
+ * once per provider lifetime — the standing workspace-root grant per
348
+ * workspace and a revocable, RANDOM private-temp capability per live
349
+ * session/workspace pair. The runner receives `--write-sid` plus
350
+ * `--temp-write-sid` and grants nothing itself. Agentless workspace-write
351
+ * calls pass the ambient temp ROOT and no SID flags: the runner creates and
352
+ * removes a random private child directory for that one invocation.
353
+ * @param policy - the resolved per-call policy.
354
+ * @returns the runner invocation.
355
+ */
356
+ windowsAclRunnerArgv(policy) {
357
+ const sessionId = policy.sessionId;
358
+ if (sessionId === void 0 || policy.mode === "read-only") return [
359
+ ...this.windowsAclRunnerInvocation(),
360
+ "--workspace",
361
+ policy.workspaceRoot,
362
+ "--temp",
363
+ tmpdir(),
364
+ "--mode",
365
+ policy.mode
366
+ ];
367
+ const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot);
368
+ return [
369
+ ...this.windowsAclRunnerInvocation(),
370
+ "--workspace",
371
+ policy.workspaceRoot,
372
+ "--temp",
373
+ temp.dir,
374
+ "--mode",
375
+ policy.mode,
376
+ "--write-sid",
377
+ workspaceWriteSid(policy.workspaceRoot),
378
+ "--temp-write-sid",
379
+ temp.writeSid
380
+ ];
381
+ }
382
+ /**
383
+ * Materialize one workspace-write policy's ACEs once per provider
384
+ * lifetime. The workspace SID and standing root grant are shared by the
385
+ * workspace. The temp directory is random and carries a distinct SID, so
386
+ * another session on the same workspace cannot use the shared workspace
387
+ * SID to enter it. A fresh provider always chooses a new path; crash
388
+ * residue therefore cannot collide with or authorize a resumed session.
389
+ * Fail-closed: a half-materialized temp grant is revoked and its directory
390
+ * removed before the error propagates.
391
+ * @param sessionId - the policy's calling-session identity.
392
+ * @param workspaceRoot - the resolved policy root.
393
+ * @returns the pair's private temp directory and write capability.
394
+ */
395
+ materializeAclGrant(sessionId, workspaceRoot) {
396
+ assertTempRootOutsideWorkspace(workspaceRoot, tmpdir());
397
+ const writeSid = workspaceWriteSid(workspaceRoot);
398
+ if (!this.workspaceGrants.has(workspaceRoot)) {
399
+ const grant = AclWriteGrant.create(writeSid);
400
+ try {
401
+ grant.add(workspaceRoot, true);
402
+ } catch (error) {
403
+ try {
404
+ grant.dispose();
405
+ } catch (cleanupError) {
406
+ throw new AggregateError([error, cleanupError], "sandbox-local windows-acl workspace grant failed and its cleanup also failed");
407
+ }
408
+ throw error;
409
+ }
410
+ this.workspaceGrants.set(workspaceRoot, grant);
411
+ }
412
+ const key = JSON.stringify([String(sessionId), workspaceRoot]);
413
+ const existing = this.tempCapabilities.get(key);
414
+ if (existing !== void 0) return existing;
415
+ const tempDir = mkdtempSync(join(tmpdir(), "qilin-"));
416
+ const tempSid = tempWriteSid(tempDir);
417
+ let grant;
418
+ try {
419
+ grant = AclWriteGrant.create(tempSid);
420
+ grant.add(tempDir);
421
+ } catch (error) {
422
+ const cleanupFailures = [];
423
+ if (grant !== void 0) try {
424
+ grant.dispose();
425
+ } catch (cleanupError) {
426
+ cleanupFailures.push(cleanupError);
427
+ }
428
+ try {
429
+ this.removeTempDir(tempDir);
430
+ } catch (cleanupError) {
431
+ cleanupFailures.push(cleanupError);
432
+ }
433
+ if (cleanupFailures.length > 0) throw new AggregateError([error, ...cleanupFailures], "sandbox-local windows-acl temp grant materialization failed and its cleanup also failed");
434
+ throw error;
435
+ }
436
+ const capability = {
437
+ dir: tempDir,
438
+ writeSid: tempSid,
439
+ grant
440
+ };
441
+ this.tempCapabilities.set(key, capability);
442
+ return capability;
443
+ }
444
+ /**
445
+ * Dispose every write grant (provider dispose): the revocable temp ACEs
446
+ * are revoked, the private temp directories this provider created are
447
+ * removed, and every SID allocation is freed; the standing workspace ACEs
448
+ * stay (the reuse cache). Cleanup failures are reported, not thrown:
449
+ * cordis teardown must not be aborted by grant cleanup. A crash skips all
450
+ * of it, but a new provider never reuses the residue's random path or SID;
451
+ * OS temp hygiene (or manual removal) eventually reclaims it.
452
+ */
453
+ revokeAclGrants() {
454
+ if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return;
455
+ const failures = [];
456
+ for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map((capability) => capability.grant)]) try {
457
+ grant.dispose();
458
+ } catch (error) {
459
+ failures.push(error);
460
+ }
461
+ for (const { dir } of this.tempCapabilities.values()) try {
462
+ this.removeTempDir(dir);
463
+ } catch (error) {
464
+ failures.push(error);
465
+ }
466
+ this.workspaceGrants.clear();
467
+ this.tempCapabilities.clear();
468
+ if (failures.length > 0) {
469
+ this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`);
470
+ for (const error of failures) this.ctx.logger.warn(error);
471
+ }
472
+ }
473
+ /** Remove one provider-owned private temp directory (injectable for cleanup tests). */
474
+ removeTempDir(dir) {
475
+ (this.internals.rmTempDir ?? ((path) => {
476
+ rmSync(path, {
477
+ recursive: true,
478
+ force: true
479
+ });
480
+ }))(dir);
481
+ }
482
+ /**
483
+ * Resolve which runner confines commands, once, for the provider's
484
+ * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
485
+ * candidate selected directly, multiple candidates arbitrated by
486
+ * functional probes in chain order. Fail closed when the platform has no
487
+ * chain or no candidate passes — the command never runs.
488
+ */
489
+ selectRunner(mode) {
490
+ this.selectedRunner ??= this.chainVerdict();
491
+ if (this.selectedRunner === "unavailable") throw new SandboxUnavailableError(mode);
492
+ return this.selectedRunner;
493
+ }
494
+ /** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
495
+ chainVerdict() {
496
+ const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? [];
497
+ const [first, ...rest] = chain;
498
+ if (first === void 0) return "unavailable";
499
+ if (rest.length === 0) return {
500
+ runner: first,
501
+ enforcement: STATIC_ENFORCEMENT[first]
502
+ };
503
+ for (const runner of chain) {
504
+ const enforcement = this.probeRunner(runner);
505
+ if (enforcement !== "unusable") return {
506
+ runner,
507
+ enforcement
508
+ };
509
+ }
510
+ return "unavailable";
511
+ }
512
+ /** One rung's functional probe (each at most once, via the chain walk). */
513
+ probeRunner(runner) {
514
+ switch (runner) {
515
+ case "bwrap": return (this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)))() ? "full" : "unusable";
516
+ case "landlock": return (this.internals.probeLandlock ?? ((launcher) => probe(launcher, { timeoutMs: this.probeTimeoutMs })))(this.landlockLauncher());
517
+ case "seatbelt": return (this.internals.probeSeatbelt ?? ((exec) => defaultProbeSeatbelt(exec, this.probeTimeoutMs)))(this.seatbeltExec()) ? "full" : "unusable";
518
+ case "windows-acl": return (this.internals.probeWindowsAcl ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)))() ? "partial" : "unusable";
519
+ default: return assertNever(runner);
520
+ }
521
+ }
522
+ /** The Landlock launcher to probe and exec (test hook over the resolved one). */
523
+ landlockLauncher() {
524
+ return this.internals.landlockLauncher ?? launcherPath();
525
+ }
526
+ /** The `sandbox-exec` executable to probe and exec (test hook over the system one). */
527
+ seatbeltExec() {
528
+ return this.internals.seatbeltExec ?? "sandbox-exec";
529
+ }
530
+ /**
531
+ * The windows-acl runner argv prefix: the built lib/runner.js entry when
532
+ * present (production), else the package source through tsx (development).
533
+ * Pin the source loader and TypeScript paths to this installation, independently
534
+ * of target cwd or environment overrides.
535
+ * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps
536
+ * the same argv contract and only swaps these entries.
537
+ */
538
+ windowsAclRunnerInvocation() {
539
+ const override = this.internals.windowsAclRunnerArgs;
540
+ if (override !== void 0) return override;
541
+ const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve("@qilin-agent/sandbox-windows-acl/runner"));
542
+ if (existsSync(builtEntry)) return [process.execPath, builtEntry];
543
+ const sourceEntry = fileURLToPath(import.meta.resolve("@qilin-agent/sandbox-windows-acl/src/runner.ts"));
544
+ const sourceConfig = fileURLToPath(new URL("../../../../tsconfig.base.json", import.meta.url));
545
+ const registration = `import { register } from ${JSON.stringify(import.meta.resolve("tsx/esm/api"))}; register({ tsconfig: ${JSON.stringify(sourceConfig)} });`;
546
+ return [
547
+ process.execPath,
548
+ "--import",
549
+ `data:text/javascript,${encodeURIComponent(registration)}`,
550
+ sourceEntry
551
+ ];
552
+ }
553
+ };
554
+ //#endregion
555
+ export { LocalSandboxProvider, LocalSandboxProvider as default };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Local sandbox backend. It selects the platform runner chain (Linux bwrap then
3
+ * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes
4
+ * competing candidates once, and reports each wrap's enforcement and stderr
5
+ * classification facts. Missing or unusable confinement fails closed rather
6
+ * than returning the original argv.
7
+ *
8
+ * The windows-acl rung additionally owns the write grants: the write SID is
9
+ * the per-WORKSPACE identity derived from the canonical workspace path
10
+ * (`workspaceWriteSid`), while every live session receives a RANDOM private
11
+ * temp directory and its own derived capability (`tempWriteSid`). The
12
+ * workspace-root ACE materializes once per workspace per server lifetime
13
+ * and STANDS (the cross-session reuse cache — the exact-ACE skip makes
14
+ * every later provision O(1) instead of re-propagating the tree per
15
+ * session); the private-temp ACEs are revoked on dispose. The runner
16
+ * receives both SIDs (their presence marks the seam-managed contract) and
17
+ * stops managing DACLs itself. The rung reports partial enforcement because
18
+ * WRITE_RESTRICTED must retain Everyone in its
19
+ * restricting list and NTFS hard links alias one file object across paths.
20
+ * @module @qilin-agent/sandbox-local
21
+ */
22
+ import { Context } from '@qilin-agent/kylin';
23
+ import z from '@qilin-agent/schemastery';
24
+ import { SandboxProvider } from '@qilin-agent/sandbox';
25
+ import type { ConfinedArgv, SandboxEnforcement, SandboxPolicy } from '@qilin-agent/sandbox';
26
+ /** Plugin config. All optional — `static Config` supplies the defaults. */
27
+ export interface Config {
28
+ /**
29
+ * Override the runner argv; bwrap-compatible profile arguments are appended. A
30
+ * non-empty override asserts full enforcement and skips built-in selection and
31
+ * probing. A runner that starts but refuses its profile must be identifiable by
32
+ * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after
33
+ * confirming the workdir is usable. `ENOENT` or `EACCES` identifies the runner when
34
+ * `error.path` equals argv[0] and `error.syscall` is `spawn` or `spawn <runner>`, or
35
+ * when `error.path` is absent and `error.syscall` is exactly `spawn <runner>`.
36
+ */
37
+ runnerCommand?: string[];
38
+ /**
39
+ * Case-insensitive stderr substrings emitted when a configured
40
+ * {@link runnerCommand} refuses its profile before executing the wrapped
41
+ * command. Required and non-empty with `runnerCommand`; rejected without
42
+ * it. Each entry is a non-empty, single-line, case-insensitive substring
43
+ * covering the executable runner's own failure dialect.
44
+ */
45
+ runnerFailureSignatures?: string[];
46
+ /** Positive timeout for each functional probe; zero would mean unbounded to Node. */
47
+ probeTimeoutMs?: number;
48
+ }
49
+ /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
50
+ export interface SandboxInternals {
51
+ /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
52
+ platform?: string;
53
+ /** Replaces the platform's chain wholesale (walk mechanics — e.g. probing a rung the product chains only reach unprobed). */
54
+ chain?: readonly SelectedRunner['runner'][];
55
+ /** Replaces the functional `bwrap` probe (the Linux chain's first rung). */
56
+ probeBwrap?: () => boolean;
57
+ /** Replaces the functional Landlock launcher probe (the Linux chain's second rung). */
58
+ probeLandlock?: (launcher: string) => SandboxEnforcement | 'unusable';
59
+ /** Replaces the functional Seatbelt probe (the darwin chain's sole rung — only consulted if that chain ever grows). */
60
+ probeSeatbelt?: (seatbeltExec: string) => boolean;
61
+ /** Replaces the resolved `landlock-run` launcher path (a fake launcher script). */
62
+ landlockLauncher?: string;
63
+ /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
64
+ seatbeltExec?: string;
65
+ /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
66
+ windowsAclRunnerArgs?: string[];
67
+ /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */
68
+ windowsAclRunnerEntry?: string;
69
+ /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */
70
+ probeWindowsAcl?: () => boolean;
71
+ /** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */
72
+ rmTempDir?: (path: string) => void;
73
+ }
74
+ /** The chain's verdict: which runner confines, and how completely it enforces. */
75
+ type SelectedRunner = {
76
+ runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl';
77
+ enforcement: SandboxEnforcement;
78
+ };
79
+ /**
80
+ * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
81
+ * chain verdict and, on the windows-acl rung, the write grants
82
+ * ({@link AclWriteGrant}: the standing workspace-root grant per workspace
83
+ * and the revocable private-temp grant per live session/workspace pair, the
84
+ * latter revoked on provider dispose); the one-time probes spawn nothing
85
+ * else.
86
+ */
87
+ export declare class LocalSandboxProvider extends SandboxProvider {
88
+ static Config: z<Config>;
89
+ /** Test hook (mirrors the bash executors' `internals`). */
90
+ internals: SandboxInternals;
91
+ private readonly runnerCommand;
92
+ private readonly configuredRunnerFailureSignatures;
93
+ private readonly probeTimeoutMs;
94
+ /** Cached chain verdict; undefined until the first confined wrap needs it. */
95
+ private selectedRunner;
96
+ /**
97
+ * Server-lifetime write grants (windows-acl rung): the STANDING
98
+ * workspace-root grant per workspace (its ACE is the cross-session reuse
99
+ * cache and outlives the provider — never revoked) and the REVOCABLE
100
+ * private-temp grant per live session/workspace pair (revoked on provider
101
+ * dispose).
102
+ */
103
+ private readonly workspaceGrants;
104
+ private readonly tempCapabilities;
105
+ constructor(ctx: Context, config: Config);
106
+ /**
107
+ * Wrap `argv` in the selected runner's invocation for `policy` — the configured
108
+ * `runnerCommand` when present (the operator's assertion, no probe), else the platform
109
+ * chain's runner speaking its own profile dialect.
110
+ *
111
+ * @param argv - the exact argv the caller is about to spawn.
112
+ * @param policy - the file-effect policy this execution runs under.
113
+ * @param signal - cancellation before policy resolution or grant creation.
114
+ * @returns the wrapped argv plus the selected backend's enforcement completeness, denial
115
+ * signatures, and structured runner-failure rules; throws the fail-closed
116
+ * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
117
+ */
118
+ confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>;
119
+ /** The selected rung's runner invocation (program + profile arguments) for one policy. */
120
+ private runnerArgv;
121
+ /**
122
+ * The windows-acl runner argv for one policy. With a calling session (the
123
+ * policy's `sessionId`) under workspace-write, the grants are materialized
124
+ * once per provider lifetime — the standing workspace-root grant per
125
+ * workspace and a revocable, RANDOM private-temp capability per live
126
+ * session/workspace pair. The runner receives `--write-sid` plus
127
+ * `--temp-write-sid` and grants nothing itself. Agentless workspace-write
128
+ * calls pass the ambient temp ROOT and no SID flags: the runner creates and
129
+ * removes a random private child directory for that one invocation.
130
+ * @param policy - the resolved per-call policy.
131
+ * @returns the runner invocation.
132
+ */
133
+ private windowsAclRunnerArgv;
134
+ /**
135
+ * Materialize one workspace-write policy's ACEs once per provider
136
+ * lifetime. The workspace SID and standing root grant are shared by the
137
+ * workspace. The temp directory is random and carries a distinct SID, so
138
+ * another session on the same workspace cannot use the shared workspace
139
+ * SID to enter it. A fresh provider always chooses a new path; crash
140
+ * residue therefore cannot collide with or authorize a resumed session.
141
+ * Fail-closed: a half-materialized temp grant is revoked and its directory
142
+ * removed before the error propagates.
143
+ * @param sessionId - the policy's calling-session identity.
144
+ * @param workspaceRoot - the resolved policy root.
145
+ * @returns the pair's private temp directory and write capability.
146
+ */
147
+ private materializeAclGrant;
148
+ /**
149
+ * Dispose every write grant (provider dispose): the revocable temp ACEs
150
+ * are revoked, the private temp directories this provider created are
151
+ * removed, and every SID allocation is freed; the standing workspace ACEs
152
+ * stay (the reuse cache). Cleanup failures are reported, not thrown:
153
+ * cordis teardown must not be aborted by grant cleanup. A crash skips all
154
+ * of it, but a new provider never reuses the residue's random path or SID;
155
+ * OS temp hygiene (or manual removal) eventually reclaims it.
156
+ */
157
+ private revokeAclGrants;
158
+ /** Remove one provider-owned private temp directory (injectable for cleanup tests). */
159
+ private removeTempDir;
160
+ /**
161
+ * Resolve which runner confines commands, once, for the provider's
162
+ * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
163
+ * candidate selected directly, multiple candidates arbitrated by
164
+ * functional probes in chain order. Fail closed when the platform has no
165
+ * chain or no candidate passes — the command never runs.
166
+ */
167
+ private selectRunner;
168
+ /** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
169
+ private chainVerdict;
170
+ /** One rung's functional probe (each at most once, via the chain walk). */
171
+ private probeRunner;
172
+ /** The Landlock launcher to probe and exec (test hook over the resolved one). */
173
+ private landlockLauncher;
174
+ /** The `sandbox-exec` executable to probe and exec (test hook over the system one). */
175
+ private seatbeltExec;
176
+ /**
177
+ * The windows-acl runner argv prefix: the built lib/runner.js entry when
178
+ * present (production), else the package source through tsx (development).
179
+ * Pin the source loader and TypeScript paths to this installation, independently
180
+ * of target cwd or environment overrides.
181
+ * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps
182
+ * the same argv contract and only swaps these entries.
183
+ */
184
+ private windowsAclRunnerInvocation;
185
+ }
186
+ export default LocalSandboxProvider;
187
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Internal platform-profile builders for the local sandbox provider.
3
+ *
4
+ * @module @qilin-agent/sandbox-local/profiles
5
+ */
6
+ import type { SandboxPolicy } from '@qilin-agent/sandbox';
7
+ /**
8
+ * Build the bwrap profile arguments for one file-effect policy.
9
+ * @param policy - file-effect policy to express as bwrap mounts.
10
+ * @returns profile arguments before the trailing separator and command argv.
11
+ */
12
+ export declare function bwrapProfileArgs(policy: SandboxPolicy): string[];
13
+ /**
14
+ * Build the Landlock launcher grants for one file-effect policy.
15
+ * @param policy - file-effect policy to express as Landlock allow-list grants.
16
+ * @returns launcher grant arguments before the trailing separator and command argv.
17
+ */
18
+ export declare function landlockProfileArgs(policy: SandboxPolicy): string[];
19
+ /**
20
+ * Build the sandbox-exec arguments and SBPL profile for one policy. The
21
+ * writable roots come from the shared {@link writableRoots} helper (canonical,
22
+ * deduplicated) so the Seatbelt grant and the in-process fs fence
23
+ * (`@qilin-agent/fs-sandbox`) can never drift apart.
24
+ * @param policy - file-effect policy to express as an SBPL profile.
25
+ * @returns sandbox-exec arguments before the trailing separator and command argv.
26
+ */
27
+ export declare function seatbeltProfileArgs(policy: SandboxPolicy): string[];
28
+ //# sourceMappingURL=profiles.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@qilin-agent/sandbox-local",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Local process-sandbox backends for the QiLin sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed",
4
+ "version": "3.1.4",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kkutysllb/QiLin.git",
11
+ "directory": "packages/sandbox/sandbox-local"
12
+ },
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "types": "lib/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./src/*": "./src/*",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib/index.js",
29
+ "lib/types/**/*.d.ts"
30
+ ],
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@qilin-agent/kylin": "~4.0.3",
34
+ "@qilin-agent/sandbox": "3.1.4",
35
+ "@qilin-agent/llm": "3.1.4",
36
+ "@qilin-agent/session": "3.1.4"
37
+ },
38
+ "dependencies": {
39
+ "@qilin-agent/sandbox-windows-acl": "3.1.4",
40
+ "@qilin-agent/schemastery": "~3.18.3",
41
+ "@qilin-agent/node-addon-system": "~0.1.2",
42
+ "@qilin-agent/util-values": "3.1.4"
43
+ },
44
+ "devDependencies": {
45
+ "@qilin-agent/kylin": "~4.0.3",
46
+ "@qilin-agent/llm": "3.1.4",
47
+ "@qilin-agent/sandbox": "3.1.4",
48
+ "@qilin-agent/session": "3.1.4"
49
+ }
6
50
  }