@morlay/dsh-sandbox-local 0.0.2

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/dist/index.mjs ADDED
@@ -0,0 +1,585 @@
1
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
2
+ import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
3
+ import { FsError } from "@deepseek-ai/dsh-fs";
4
+ import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
5
+ import { stat } from "node:fs/promises";
6
+ import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
7
+ import z from "@deepseek-ai/schemastery";
8
+ //#region src/rules.ts
9
+ /**
10
+ * 规则编译:`access` 条目解析(`rw <path>` 额外可写根 / `r- <path>` 只读 /
11
+ * `-- <pattern>` 拒绝访问)、`{{ env.NAME }}` 模板展开、相对工作区路径的绝对化、
12
+ * glob → 正则,以及编译结果的匹配。
13
+ *
14
+ * 命中优先级:`--` 拒绝覆盖一切;`r-` 只读覆盖可写授予;都未命中时才按可写根判定。
15
+ *
16
+ * 生成的正则源码同时交给进程内检查(JS `RegExp`)与 macOS Seatbelt profile
17
+ * (SBPL 的 `(regex #"…")`),因此只用 POSIX ERE 与 JS 正则共有的语法。
18
+ * @module @morlay/dsh-sandbox-local/rules
19
+ */
20
+ /** `{{ env.NAME }}` 模板;名字限定为环境变量的字符集。 */
21
+ const ENV_TEMPLATE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
22
+ /** `access` 的条目形式:`rw <path>` / `r- <path>` / `-- <pattern>`;前缀后必须有空白。 */
23
+ const ACCESS_LINE = /^(rw|r-|--)(?:\s+(.*))?$/u;
24
+ /** glob 元字符:规则里出现即按模式匹配,否则按字面路径(含其全部后代)匹配。 */
25
+ const GLOB_META = /[*?[]/;
26
+ /** glob 翻译时需要转义的正则元字符(字符类内部除外)。 */
27
+ const REGEX_META = /[\\^$+.(){}|]/;
28
+ /** 展开规则里的 `{{ env.NAME }}`;引用未定义或为空的环境变量直接抛错。 */
29
+ function expandEnvTemplates(value, env) {
30
+ return value.replace(ENV_TEMPLATE, (_match, name) => {
31
+ const resolved = env[name];
32
+ if (resolved === void 0 || resolved.length === 0) throw new Error(`sandbox rules: "${value}" references the unset environment variable "${name}"`);
33
+ return resolved;
34
+ });
35
+ }
36
+ /**
37
+ * 把含 glob 的规则翻译成正则源码。
38
+ * `**` 跨目录层级(`**` 与“`**` 后接斜杠”都能匹配零层),`*` 与 `?` 不跨 `/`,
39
+ * 字符类透传(`[!…]` 按 glob 习惯翻成 `[^…]`),其余正则元字符转义。
40
+ * @param pattern - 已绝对化的 glob 规则。
41
+ * @returns 可同时被 JS `RegExp` 与 SBPL regex 接受的正则源码。
42
+ */
43
+ function globToRegexSource(pattern) {
44
+ let source = "";
45
+ for (let index = 0; index < pattern.length; index += 1) {
46
+ const char = pattern[index];
47
+ if (char === "*") {
48
+ if (pattern[index + 1] === "*") {
49
+ index += 1;
50
+ if (pattern[index + 1] === "/") {
51
+ index += 1;
52
+ source += "(.*/)?";
53
+ } else source += ".*";
54
+ } else source += "[^/]*";
55
+ continue;
56
+ }
57
+ if (char === "?") {
58
+ source += "[^/]";
59
+ continue;
60
+ }
61
+ if (char === "[") {
62
+ const close = pattern.indexOf("]", index + 1);
63
+ if (close === -1) {
64
+ source += "\\[";
65
+ continue;
66
+ }
67
+ const body = pattern.slice(index + 1, close);
68
+ if (body.length === 0) {
69
+ source += "\\[\\]";
70
+ index = close;
71
+ continue;
72
+ }
73
+ source += body.startsWith("!") ? `[^${body.slice(1)}]` : `[${body}]`;
74
+ index = close;
75
+ continue;
76
+ }
77
+ source += REGEX_META.test(char) ? `\\${char}` : char;
78
+ }
79
+ return source;
80
+ }
81
+ /** 规则是否为空——空规则下 provider 不改写任何 runner 参数。 */
82
+ function isEmptyRules(rules) {
83
+ return rules.allowRoots.length === 0 && rules.readOnlySubtrees.length === 0 && rules.readOnlyPatterns.length === 0 && rules.denySubtrees.length === 0 && rules.denyPatterns.length === 0;
84
+ }
85
+ /** 只读模式不因 `rw` 条目放松:投影出只保留 `r-` / `--` 条目的规则集。 */
86
+ function withoutAllowRoots(rules) {
87
+ return rules.allowRoots.length === 0 ? rules : {
88
+ ...rules,
89
+ allowRoots: []
90
+ };
91
+ }
92
+ /**
93
+ * 解析 `access` 配置:接受字符串数组(每项一条规则)或多行文本(每行一条规则),
94
+ * 空行忽略;`rw <path>` 是额外可写根,`r- <path>` 是只读,`-- <pattern>` 是访问
95
+ * 拒绝(读 + 写)。缺前缀或前缀后没有路径都直接报错——规则不因写法歧义而变形。
96
+ * @param input - 配置里的 `access` 值。
97
+ * @returns `allowWrite` / `readOnly` / `deny` 三组规则(模板尚未展开)。
98
+ */
99
+ function parseAccess(input) {
100
+ const lines = (input === void 0 ? [] : typeof input === "string" ? [input] : [...input]).flatMap((value) => value.split(/\r?\n/u)).map((line) => line.trim()).filter((line) => line.length > 0);
101
+ const allowWrite = [];
102
+ const readOnly = [];
103
+ const deny = [];
104
+ for (const line of lines) {
105
+ const match = ACCESS_LINE.exec(line);
106
+ if (match === null) throw new Error(`sandbox rules: access entry ${JSON.stringify(line)} must start with "rw " (write), "r- " (read-only) or "-- " (deny)`);
107
+ const rule = (match[2] ?? "").trim();
108
+ if (rule.length === 0) throw new Error(`sandbox rules: access entry ${JSON.stringify(line)} carries no path`);
109
+ if (match[1] === "rw") allowWrite.push(rule);
110
+ else if (match[1] === "r-") readOnly.push(rule);
111
+ else deny.push(rule);
112
+ }
113
+ return {
114
+ allowWrite,
115
+ readOnly,
116
+ deny
117
+ };
118
+ }
119
+ /**
120
+ * 把配置里的 `access` 转成规则来源:模板在这一步展开(加载期,fail-fast),
121
+ * 相对路径留待按调用时的工作区根绝对化。
122
+ * @param config - 含 `access` 的插件配置。
123
+ * @param env - 模板展开用的进程环境。
124
+ * @returns 模板已展开的规则来源。
125
+ */
126
+ function ruleSourceOf(config, env) {
127
+ const parsed = parseAccess(config.access);
128
+ const expand = (values) => values.map((value) => expandEnvTemplates(value, env));
129
+ return {
130
+ allowWrite: expand(parsed.allowWrite),
131
+ readOnly: expand(parsed.readOnly),
132
+ deny: expand(parsed.deny)
133
+ };
134
+ }
135
+ /** 绝对化一条规则:相对路径相对工作区根。 */
136
+ function absolutize(value, workspaceRoot) {
137
+ return isAbsolute(value) ? value : resolve(workspaceRoot, value);
138
+ }
139
+ /** 编译一组字面路径 / glob 规则:字面项按子树(含全部后代)匹配,glob 项按整串匹配。 */
140
+ function compilePaths(values, workspaceRoot) {
141
+ const subtrees = [];
142
+ const patterns = [];
143
+ for (const value of values) {
144
+ const absolute = absolutize(value, workspaceRoot);
145
+ if (GLOB_META.test(value)) {
146
+ const pattern = `^${globToRegexSource(absolute)}$`;
147
+ patterns.push({
148
+ source: pattern,
149
+ regex: new RegExp(pattern)
150
+ });
151
+ } else subtrees.push(canonicalPath(absolute));
152
+ }
153
+ return {
154
+ subtrees,
155
+ patterns
156
+ };
157
+ }
158
+ /**
159
+ * 按一个工作区根编译规则。
160
+ * `rw` 条目必须是具体路径(glob 无法表达“可写根”);`r-` 与 `--` 条目允许 glob。
161
+ * @param source - 已展开模板的规则来源。
162
+ * @param workspaceRoot - 相对规则解析用的工作区根(canonical 绝对路径)。
163
+ * @returns 编译后的规则。
164
+ */
165
+ function compileRules(source, workspaceRoot) {
166
+ const allowRoots = [];
167
+ for (const value of source.allowWrite) {
168
+ if (GLOB_META.test(value)) throw new Error(`sandbox rules: rw entry "${value}" must name a concrete path, not a glob`);
169
+ allowRoots.push(canonicalPath(absolutize(value, workspaceRoot)));
170
+ }
171
+ const readOnly = compilePaths(source.readOnly, workspaceRoot);
172
+ const deny = compilePaths(source.deny, workspaceRoot);
173
+ return {
174
+ allowRoots,
175
+ readOnlySubtrees: readOnly.subtrees,
176
+ readOnlyPatterns: readOnly.patterns,
177
+ denySubtrees: deny.subtrees,
178
+ denyPatterns: deny.patterns
179
+ };
180
+ }
181
+ /** 字面规则的后代判定(canonical 拼写,与 Seatbelt `subpath` 同一语义)。 */
182
+ function isUnder(path, root) {
183
+ if (path === root) return true;
184
+ return path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
185
+ }
186
+ /** 命中任一子树或任一模式。 */
187
+ function matches(canonicalTarget, subtrees, patterns) {
188
+ for (const subtree of subtrees) if (isUnder(canonicalTarget, subtree)) return true;
189
+ return patterns.some((pattern) => pattern.regex.test(canonicalTarget));
190
+ }
191
+ /**
192
+ * 判断目标是否被 `--` 条目命中(读与写都拒)。
193
+ * @param rules - 已编译规则。
194
+ * @param canonicalTarget - 目标的 canonical 路径。
195
+ * @returns 命中即 true。
196
+ */
197
+ function isDenied(rules, canonicalTarget) {
198
+ return matches(canonicalTarget, rules.denySubtrees, rules.denyPatterns);
199
+ }
200
+ /**
201
+ * 判断目标是否被 `r-` 条目命中(读放行、写拒绝)。
202
+ * @param rules - 已编译规则。
203
+ * @param canonicalTarget - 目标的 canonical 路径。
204
+ * @returns 命中即 true。
205
+ */
206
+ function isReadOnly(rules, canonicalTarget) {
207
+ return matches(canonicalTarget, rules.readOnlySubtrees, rules.readOnlyPatterns);
208
+ }
209
+ //#endregion
210
+ //#region src/dialects.ts
211
+ /**
212
+ * 在官方 provider 生成的 confined argv 上追加本包规则:
213
+ * Seatbelt 追加 SBPL 规则(后置规则覆盖先前的 allow,实测 `(deny file-read* file-write*
214
+ * (subpath …))` 能压过 `(allow file-write* (subpath …))`,`r-` 条目则只 deny 写入),
215
+ * bwrap 追加挂载参数(`rw` 用 `--bind-try`,`r-` 与 `--` 都用 `--ro-bind-try`),
216
+ * Landlock 只能追加可写授权(其 allow-list 语义无法减除子路径),
217
+ * Windows ACL runner 没有对应表达。
218
+ *
219
+ * 方言从官方 `ConfinedArgv.argv` 的结构识别:runner 参数在前,`--` 之后是调用方 argv。
220
+ * @module @morlay/dsh-sandbox-local/dialects
221
+ */
222
+ /** runner 部分与调用方 argv 的分隔符。 */
223
+ const SEPARATOR = "--";
224
+ /** 各方言的规则表达能力——加载期据此告警,运行期据此决定是否改写参数。 */
225
+ const DIALECT_CAPABILITIES = {
226
+ seatbelt: {
227
+ allowWrite: true,
228
+ readOnly: true,
229
+ denyReadWrite: true,
230
+ denyWriteOnly: false
231
+ },
232
+ bwrap: {
233
+ allowWrite: true,
234
+ readOnly: true,
235
+ denyReadWrite: false,
236
+ denyWriteOnly: true
237
+ },
238
+ landlock: {
239
+ allowWrite: true,
240
+ readOnly: false,
241
+ denyReadWrite: false,
242
+ denyWriteOnly: false
243
+ },
244
+ "windows-acl": {
245
+ allowWrite: false,
246
+ readOnly: false,
247
+ denyReadWrite: false,
248
+ denyWriteOnly: false
249
+ }
250
+ };
251
+ /** runner 部分的结束位置。 */
252
+ function separatorIndex(argv) {
253
+ const index = argv.indexOf(SEPARATOR);
254
+ if (index === -1) throw new Error("sandbox rules: the confined argv carries no `--` separator to extend");
255
+ return index;
256
+ }
257
+ /**
258
+ * 识别 argv 使用的执行方言。
259
+ * @param argv - 官方 provider 返回的完整 confined argv。
260
+ * @returns 方言,或无法识别时的 `undefined`(例如运维自定义的 runnerCommand)。
261
+ */
262
+ function dialectOf(argv) {
263
+ const separator = argv.indexOf(SEPARATOR);
264
+ const runner = separator === -1 ? argv : argv.slice(0, separator);
265
+ if (runner.includes("-p")) return "seatbelt";
266
+ if (runner[0] === "bwrap") return "bwrap";
267
+ if (runner.includes("--workspace")) return "windows-acl";
268
+ if (runner.includes("--ro") || runner.includes("--rw")) return "landlock";
269
+ }
270
+ /** SBPL 字符串字面量。 */
271
+ function sbplString(value) {
272
+ return `"${value.replaceAll("\\", String.raw`\\`).replaceAll("\"", String.raw`\"`)}"`;
273
+ }
274
+ /**
275
+ * SBPL `#"…"` 的正则体:正则自身的 `\` 必须保留(glob 翻译用它转义元字符),
276
+ * 因此拒绝项里出现双引号时直接报错,而不是产出一个含义变化的 profile。
277
+ */
278
+ function sbplRegexBody(source) {
279
+ if (source.includes("\"")) throw new Error(`sandbox rules: deny pattern ${JSON.stringify(source)} cannot be expressed in a Seatbelt profile`);
280
+ return source;
281
+ }
282
+ /** Seatbelt:把 allow / deny 规则追加到 `-p` 的 profile 文本末尾。 */
283
+ function extendSeatbelt(argv, rules) {
284
+ const index = argv.indexOf("-p");
285
+ const profile = index === -1 ? void 0 : argv[index + 1];
286
+ if (profile === void 0) throw new Error("sandbox rules: the Seatbelt runner argv carries no `-p` profile to extend");
287
+ const extended = `${profile} ${[
288
+ ...rules.allowRoots.map((root) => `(allow file-write* (subpath ${sbplString(root)}))`),
289
+ ...rules.readOnlySubtrees.map((path) => `(deny file-write* (subpath ${sbplString(path)}))`),
290
+ ...rules.readOnlyPatterns.map((pattern) => `(deny file-write* (regex #"${sbplRegexBody(pattern.source)}"))`),
291
+ ...rules.denySubtrees.map((path) => `(deny file-read* file-write* (subpath ${sbplString(path)}))`),
292
+ ...rules.denyPatterns.map((pattern) => `(deny file-read* file-write* (regex #"${sbplRegexBody(pattern.source)}"))`)
293
+ ].join(" ")}`;
294
+ return [
295
+ ...argv.slice(0, index + 1),
296
+ extended,
297
+ ...argv.slice(index + 2)
298
+ ];
299
+ }
300
+ /**
301
+ * bwrap:额外可写根用 `--bind-try`(路径不存在时跳过);`r-` 与 `--` 都用
302
+ * `--ro-bind-try` 覆盖成只读(挂载后行覆盖前行)——也就是说 `--` 在 bwrap 上退化为
303
+ * “只拒写入”。glob 模式无法表达为静态挂载,由调用方在加载期告警。
304
+ */
305
+ function extendBwrap(argv, rules) {
306
+ const separator = separatorIndex(argv);
307
+ const additions = [];
308
+ for (const root of rules.allowRoots) additions.push("--bind-try", root, root);
309
+ for (const path of [...rules.readOnlySubtrees, ...rules.denySubtrees]) additions.push("--ro-bind-try", path, path);
310
+ return [
311
+ ...argv.slice(0, separator),
312
+ ...additions,
313
+ ...argv.slice(separator)
314
+ ];
315
+ }
316
+ /** Landlock:只能追加可写授权(`--rw`),`r-` 与 `--` 都无对应表达。 */
317
+ function extendLandlock(argv, rules) {
318
+ const separator = separatorIndex(argv);
319
+ const additions = rules.allowRoots.flatMap((root) => ["--rw", root]);
320
+ return [
321
+ ...argv.slice(0, separator),
322
+ ...additions,
323
+ ...argv.slice(separator)
324
+ ];
325
+ }
326
+ /**
327
+ * 按 argv 的方言追加规则。
328
+ * @param argv - 官方 provider 返回的 confined argv。
329
+ * @param rules - 已编译规则;空规则原样返回。
330
+ * @returns 追加规则后的 argv;方言无法识别时抛错(规则不能静默失效)。
331
+ */
332
+ function extendConfinedArgv(argv, rules) {
333
+ if (isEmptyRules(rules)) return [...argv];
334
+ const dialect = dialectOf(argv);
335
+ if (dialect === void 0) throw new Error("sandbox rules: cannot extend an unrecognized sandbox runner argv; drop allowWrite/deny or configure a bwrap-compatible runnerCommand");
336
+ switch (dialect) {
337
+ case "seatbelt": return extendSeatbelt(argv, rules);
338
+ case "bwrap": return extendBwrap(argv, rules);
339
+ case "landlock": return extendLandlock(argv, rules);
340
+ case "windows-acl": return [...argv];
341
+ }
342
+ }
343
+ //#endregion
344
+ //#region src/containment.ts
345
+ /** 视为“路径不存在”的错误码:只有它们能让祖先遍历继续。 */
346
+ const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
347
+ /** stat 一次,缺失返回 undefined;其它失败(权限、I/O)继续抛出。 */
348
+ async function statIfPresent(path) {
349
+ try {
350
+ return await stat(path, { bigint: true });
351
+ } catch (error) {
352
+ const code = error.code;
353
+ if (code !== void 0 && MISSING_CODES.has(code)) return void 0;
354
+ throw error;
355
+ }
356
+ }
357
+ /** 按大小写敏感性归一用于比较的拼写。 */
358
+ function comparablePath(path, caseSensitive) {
359
+ return caseSensitive ? path : path.toLowerCase();
360
+ }
361
+ /** 词法包含判定(目标可带尚不存在的后缀)。 */
362
+ function isLexicallyUnder(path, root, caseSensitive) {
363
+ const comparableTarget = comparablePath(path, caseSensitive);
364
+ const comparableRoot = comparablePath(root, caseSensitive);
365
+ if (comparableTarget === comparableRoot) return true;
366
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
367
+ return comparableTarget.startsWith(prefix);
368
+ }
369
+ /** 两次 stat 是否指向同一个文件系统对象。 */
370
+ function sameIdentity(left, right) {
371
+ return left.dev === right.dev && left.ino === right.ino;
372
+ }
373
+ /**
374
+ * 判断 canonical 目标是否就是某个根或位于其下。
375
+ * 拼写不同时(Windows 长名/8.3 别名、大小写差异)沿目标的现存祖先比较文件系统身份,
376
+ * 不把包含判定弱化成文本近似。
377
+ * @param path - 目标的 canonical 路径(可带尚不存在的尾部)。
378
+ * @param root - canonical 可写根。
379
+ * @param caseSensitive - 词法比较是否区分大小写;默认按宿主平台约定。
380
+ * @returns 目标是该根或其后代时为 true。
381
+ */
382
+ async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
383
+ if (isLexicallyUnder(path, root, caseSensitive)) return true;
384
+ const rootInfo = await statIfPresent(root);
385
+ if (rootInfo === void 0) return false;
386
+ let ancestor = path;
387
+ for (;;) {
388
+ const ancestorInfo = await statIfPresent(ancestor);
389
+ if (ancestorInfo !== void 0 && sameIdentity(ancestorInfo, rootInfo)) return true;
390
+ const parent = dirname(ancestor);
391
+ if (parent === ancestor) return false;
392
+ ancestor = parent;
393
+ }
394
+ }
395
+ //#endregion
396
+ //#region src/fs.ts
397
+ /**
398
+ * 官方本机文件系统后端的可配置版本。
399
+ * `--` 条目在解析阶段拒绝目标(覆盖 read / write / edit / list 等一切工具入口),
400
+ * `r-` 与 `--` 条目在写入前拒绝写入(优先于任何可写根),写操作再按
401
+ * `workspace-write` + `rw` 条目复核 containment。
402
+ */
403
+ var ConfigurableFileSystem = class extends LocalFileSystem {
404
+ static inject = ["sandboxPolicy"];
405
+ defaultMode;
406
+ source;
407
+ /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
408
+ compiled = /* @__PURE__ */ new Map();
409
+ constructor(ctx, config) {
410
+ super(ctx, config);
411
+ this.defaultMode = ctx.sandboxPolicy.defaultMode;
412
+ this.source = ruleSourceOf(config, process.env);
413
+ }
414
+ /** 工具层读它判断后端是否 confine(并据此广告 escalation 字段)。 */
415
+ get sandboxMode() {
416
+ return this.defaultMode;
417
+ }
418
+ /**
419
+ * 解析目标后立即执行拒绝判定:工具入口(read / write / edit / list)都先经过
420
+ * `resolve`,因此一次判定即可覆盖读与写。
421
+ * @param path - 待解析的路径。
422
+ * @param opts - cwd 与取消信号;cwd 同时是相对规则的解析根。
423
+ * @returns 解析后的目标。
424
+ */
425
+ async resolve(path, opts) {
426
+ const target = await super.resolve(path, opts);
427
+ this.assertNotDenied(this.rulesFor(opts?.cwd ?? this.ctx.sandboxPolicy.workspaceRoot), target.targetKey, target.displayPath);
428
+ return target;
429
+ }
430
+ /**
431
+ * 按 per-call 策略复核后写入。
432
+ * @param target - 工具解析出的目标。
433
+ * @param content - 新的完整内容。
434
+ * @param expected - 写入前版本守卫。
435
+ * @param signal - 取消信号。
436
+ * @param sandboxPolicy - per-call 策略;省略时用部署默认。
437
+ * @returns 上游写入结果。
438
+ */
439
+ async writeText(target, content, expected, signal, sandboxPolicy) {
440
+ return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal);
441
+ }
442
+ /**
443
+ * 按 per-call 策略复核后编辑。
444
+ * @param target - 工具解析出的目标。
445
+ * @param edit - 字面量 search/replace 请求。
446
+ * @param expected - 版本守卫。
447
+ * @param signal - 取消信号。
448
+ * @param sandboxPolicy - per-call 策略;省略时用部署默认。
449
+ * @returns 上游编辑结果。
450
+ */
451
+ async editText(target, edit, expected, signal, sandboxPolicy) {
452
+ return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal);
453
+ }
454
+ /** `--` 条目命中即拒绝访问(读与写都拒);抛 `FS_SANDBOX_DENIED`。 */
455
+ assertNotDenied(rules, canonicalTarget, displayPath) {
456
+ if (!isDenied(rules, canonicalTarget)) return;
457
+ throw new FsError(`cannot access "${displayPath}": file access denied by the configured "--" rules`, "FS_SANDBOX_DENIED");
458
+ }
459
+ /** `--` 或 `r-` 条目命中即拒绝写入,且优先于任何可写根。 */
460
+ assertWritable(rules, canonicalTarget, displayPath) {
461
+ this.assertNotDenied(rules, canonicalTarget, displayPath);
462
+ if (!isReadOnly(rules, canonicalTarget)) return;
463
+ throw new FsError(`cannot write "${displayPath}": path is read-only by the configured "r-" rule`, "FS_SANDBOX_DENIED");
464
+ }
465
+ /**
466
+ * 写前复核:`--` / `r-` 条目(任何模式)→ 模式本身的只读拒绝 → `workspace-write` 的
467
+ * `writableRoots + rw` containment,返回必须被写入的那个目标。
468
+ */
469
+ async checkedTarget(target, sandboxPolicy) {
470
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
471
+ const rules = this.rulesFor(policy.workspaceRoot);
472
+ this.assertWritable(rules, target.targetKey, target.displayPath);
473
+ const { mode } = policy;
474
+ if (mode === "danger-full-access") return target;
475
+ if (mode === "read-only") throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
476
+ const fresh = await super.resolve(target.displayPath);
477
+ this.assertWritable(rules, fresh.targetKey, fresh.displayPath);
478
+ for (const root of [...writableRoots(policy), ...rules.allowRoots]) if (await isPathUnder(fresh.targetKey, root)) return fresh;
479
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
480
+ }
481
+ /** 取(并按需编译缓存)某个工作区根下的规则。 */
482
+ rulesFor(workspaceRoot) {
483
+ const cached = this.compiled.get(workspaceRoot);
484
+ if (cached !== void 0) return cached;
485
+ const compiled = compileRules(this.source, workspaceRoot);
486
+ this.compiled.set(workspaceRoot, compiled);
487
+ return compiled;
488
+ }
489
+ };
490
+ //#endregion
491
+ //#region src/sandbox.ts
492
+ /**
493
+ * 官方本机沙箱 provider 的可配置版本。
494
+ * `read-only` 模式不追加 `rw` 条目(只读边界不因额外可写根放松);`--` 条目在两种
495
+ * confined 模式下都生效。
496
+ */
497
+ var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
498
+ source;
499
+ /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
500
+ compiled = /* @__PURE__ */ new Map();
501
+ constructor(ctx, config) {
502
+ super(ctx, config);
503
+ this.source = ruleSourceOf(config, process.env);
504
+ }
505
+ /**
506
+ * 官方拼装 + 规则追加。
507
+ * @param argv - 调用方即将 spawn 的 argv。
508
+ * @param policy - 本次调用的文件效果策略。
509
+ * @returns 追加规则后的 confined argv(空规则时与官方结果一致)。
510
+ */
511
+ confine(argv, policy) {
512
+ const confined = super.confine(argv, policy);
513
+ const rules = this.rulesFor(policy.workspaceRoot);
514
+ const effective = policy.mode === "workspace-write" ? rules : withoutAllowRoots(rules);
515
+ if (isEmptyRules(effective)) return confined;
516
+ return {
517
+ ...confined,
518
+ argv: extendConfinedArgv(confined.argv, effective)
519
+ };
520
+ }
521
+ /** 取(并按需编译缓存)某个工作区根下的规则。 */
522
+ rulesFor(workspaceRoot) {
523
+ const cached = this.compiled.get(workspaceRoot);
524
+ if (cached !== void 0) return cached;
525
+ const compiled = compileRules(this.source, workspaceRoot);
526
+ this.compiled.set(workspaceRoot, compiled);
527
+ return compiled;
528
+ }
529
+ };
530
+ //#endregion
531
+ //#region src/config.ts
532
+ /**
533
+ * 插件配置的单一真源:`access` 规则 + 透传官方的字段。
534
+ *
535
+ * 规则只在这里声明一次——provider 与 fs 后端按构造参数接收已解析的配置,
536
+ * 不各自重复声明 schema;部署层的规则值随插入本行的 patch 写死(本部署见
537
+ * `@morlay/dsh-preset`),包内不预设。
538
+ * @module @morlay/dsh-sandbox-local/config
539
+ */
540
+ /** 运行时配置 schema。 */
541
+ const Config = z.object({
542
+ access: z.union([z.array(z.string()), z.string()]).default([]),
543
+ runnerCommand: z.array(z.string()).default([]),
544
+ runnerFailureSignatures: z.array(z.string()).default([]),
545
+ probeTimeoutMs: z.natural().default(5e3),
546
+ cwd: z.string().default(process.cwd()),
547
+ diffBasisMaxBytes: z.number().default(10485760)
548
+ });
549
+ //#endregion
550
+ //#region src/index.ts
551
+ /** Cordis 插件名。 */
552
+ const name = "sandbox-local";
553
+ /** fs 侧从策略服务取默认模式与工作区回退根,所以先等 `ctx.sandboxPolicy`。 */
554
+ const inject = ["sandboxPolicy"];
555
+ /**
556
+ * 规则在进程沙箱侧的能力随平台方言变化,加载期把降级说清楚:
557
+ * `ctx.fs` 侧(read / write / edit 工具)在 macOS / Linux / Windows 上语义一致,
558
+ * 只有 bash 等子进程走平台 runner 的表达能力。
559
+ */
560
+ function warnAboutDegradedRules(ctx, config) {
561
+ const rules = ruleSourceOf(config, process.env);
562
+ const grants = rules.allowWrite.length > 0;
563
+ const readOnly = rules.readOnly.length > 0;
564
+ const denials = rules.deny.length > 0;
565
+ if (!grants && !readOnly && !denials) return;
566
+ if (process.platform === "darwin") return;
567
+ if (readOnly || denials) {
568
+ const seatbelt = DIALECT_CAPABILITIES.seatbelt.denyReadWrite;
569
+ const bwrap = DIALECT_CAPABILITIES.bwrap.denyWriteOnly;
570
+ ctx.logger.warn(`sandbox-local: "r-" / "--" entries cannot be fully enforced for confined subprocesses on ${process.platform} (Seatbelt enforces both; bwrap binds the path read-only, so "--" degrades to write-only: ${bwrap}; Landlock and the Windows ACL runner cannot express a subpath rule at all: ${seatbelt} applies to Seatbelt only) — tools that read through ctx.fs stay covered`);
571
+ }
572
+ if (grants && process.platform === "win32") ctx.logger.warn("sandbox-local: \"rw\" entries cannot be granted to confined subprocesses on win32; ctx.fs covers the extra roots, the Windows ACL runner does not");
573
+ }
574
+ /**
575
+ * 注册两个替换实现。
576
+ * @param ctx - 插件上下文(官方 `sandbox` / `fs-sandbox` 行已禁用)。
577
+ * @param config - 已由 schema 填好默认值的配置。
578
+ */
579
+ function apply(ctx, config) {
580
+ warnAboutDegradedRules(ctx, config);
581
+ new ConfigurableSandboxProvider(ctx, config);
582
+ new ConfigurableFileSystem(ctx, config);
583
+ }
584
+ //#endregion
585
+ export { Config, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@morlay/dsh-sandbox-local",
3
+ "version": "0.0.2",
4
+ "description": "Configurable sandbox bundle: replaces the shipped process-sandbox provider and filesystem fence with implementations that add extra writable roots and access denials on top of the upstream semantics.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-bundle",
8
+ "dsh-plugin",
9
+ "permission",
10
+ "sandbox"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/morlay/better-session.git"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "cordis.patch.yml",
21
+ "!**/__tests__"
22
+ ],
23
+ "type": "module",
24
+ "exports": {
25
+ ".": "./dist/index.mjs",
26
+ "./package.json": "./package.json",
27
+ "./cordis.patch.yml": "./cordis.patch.yml"
28
+ },
29
+ "devDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.2",
31
+ "@deepseek-ai/dsh-fs": "^0.1.5-rc.2",
32
+ "@deepseek-ai/dsh-fs-local": "^0.1.5-rc.2",
33
+ "@deepseek-ai/dsh-fs-sandbox": "^0.1.5-rc.2",
34
+ "@deepseek-ai/dsh-sandbox": "^0.1.5-rc.2",
35
+ "@deepseek-ai/dsh-sandbox-local": "^0.1.5-rc.2",
36
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.5-rc.2"
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/cordis": "^4.0.2",
40
+ "@deepseek-ai/dsh-fs": "^0.1.5-rc.2",
41
+ "@deepseek-ai/dsh-fs-local": "^0.1.5-rc.2",
42
+ "@deepseek-ai/dsh-fs-sandbox": "^0.1.5-rc.2",
43
+ "@deepseek-ai/dsh-sandbox": "^0.1.5-rc.2",
44
+ "@deepseek-ai/dsh-sandbox-local": "^0.1.5-rc.2",
45
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.5-rc.2",
46
+ "@deepseek-ai/schemastery": "^3.18.2"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ }
52
+ },
53
+ "scripts": {
54
+ "build": "pnpm exec tsdown"
55
+ }
56
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 插件配置的单一真源:`access` 规则 + 透传官方的字段。
3
+ *
4
+ * 规则只在这里声明一次——provider 与 fs 后端按构造参数接收已解析的配置,
5
+ * 不各自重复声明 schema;部署层的规则值随插入本行的 patch 写死(本部署见
6
+ * `@morlay/dsh-preset`),包内不预设。
7
+ * @module @morlay/dsh-sandbox-local/config
8
+ */
9
+
10
+ import z from "@deepseek-ai/schemastery";
11
+ import type { Config as UpstreamFsConfig } from "@deepseek-ai/dsh-fs-local";
12
+ import type { Config as UpstreamSandboxConfig } from "@deepseek-ai/dsh-sandbox-local";
13
+
14
+ /** 插件配置。 */
15
+ export interface Config extends UpstreamSandboxConfig, UpstreamFsConfig {
16
+ /**
17
+ * 规则条目:`rw <path>`(额外可写根)、`r- <path>`(只读)、`-- <pattern>`(拒绝访问)。
18
+ * 数组每项一条,或写一段多行文本(每行一条);`{{ env.NAME }}` 按进程环境展开。
19
+ */
20
+ access?: string | string[];
21
+ }
22
+
23
+ /** 运行时配置 schema。 */
24
+ export const Config: z<Config> = z.object({
25
+ access: z.union([z.array(z.string()), z.string()]).default([]),
26
+ runnerCommand: z.array(z.string()).default([]),
27
+ runnerFailureSignatures: z.array(z.string()).default([]),
28
+ probeTimeoutMs: z.natural().default(5_000),
29
+ cwd: z.string().default(process.cwd()),
30
+ diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
31
+ });