@morlay/dsh-sandbox-local 0.0.2 → 0.0.3-alpha.0

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/README.md CHANGED
@@ -8,12 +8,9 @@
8
8
 
9
9
  上游沙箱策略只有两个字段:`mode`(`read-only` / `workspace-write` /
10
10
  `danger-full-access`)与 `workspaceRoot`;`workspace-write` 的可写路径是硬编码的
11
- `[工作区, /tmp, os.tmpdir()]`(`packages/sandbox/sandbox/src/roots.ts:52-55`),
11
+ `[工作区, /tmp, os.tmpdir()]`(`vendor/deepseek-harness/packages/sandbox/sandbox/src/roots.ts:52-55`),
12
12
  没有任何追加可写根或拒绝项的配置面。于是「让 agent 能写 `$XDG_CACHE_HOME`,
13
- 但永远不许碰项目里的 `mise.*.toml`」这类诉求只能整块放弃隔离。上游自己实现过拒绝项
14
- (`readDenyPaths`)又撤回:bwrap 要在已置只读的树里创建挂载点、Landlock 无法从自己的
15
- `/` 读授权里减除,一个「在能生效的地方破坏隔离、在不能生效的地方谎报」的保护被判为
16
- 不如明确的缺失(`.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md:29`)。
13
+ 但永远不许碰项目里的 `mise.*.toml`」这类诉求只能整块放弃隔离。
17
14
 
18
15
  本包把「能表达多少就说多少」明确下来:Seatbelt 完整生效,其余平台按方言降级,
19
16
  并在加载期告警,而不是静默失效。
@@ -92,39 +89,17 @@
92
89
 
93
90
  ## 装配
94
91
 
95
- 本包自带 `cordis.patch.yml`(禁用官方两行 + 插入自己的一行),把本包作为独立 bundle 采用
96
- 的部署直接列进 `dsh.profile.bundles` 即可;行不带 config(schema 默认是空规则):
92
+ 本包自带 `cordis.patch.yml`(禁用官方 `sandbox` / `fs-sandbox` 两行 + 插入自己的一行),
93
+ 把本包作为**独立 bundle** 采用的部署直接列进 `dsh.profile.bundles` 即可;行不带 config
94
+ (schema 默认是空规则),patch 内容见该文件。
97
95
 
98
- ```yaml
99
- - id: sandbox
100
- disabled: true
101
-
102
- - id: fs-sandbox
103
- disabled: true
104
-
105
- - insert:
106
- - id: sandbox-local
107
- name: "@morlay/dsh-sandbox-local"
108
- ```
109
-
110
- **本部署(`@morlay/dsh-preset`)不走这条路径**:它的 patch 自己禁用官方两行、插入
111
- `- id: sandbox-local` 行并写上规则,因此示例 app 的 `dsh.profile.bundles` 不需要列出本包,
112
- 只需要 profile 的依赖树能解析模块名(`@morlay/dsh-preset` 已在 `dependencies` 声明本包)。
96
+ **本部署(`@morlay/dsh-preset`)不走这条路径**:装配(禁用官方两行 + 插入
97
+ `sandbox-local` 行)与 `access` 规则一起维护在 preset 的 bundle patch 里,因此示例 app 的
98
+ `dsh.profile.bundles` 不需要列出本包,只需要 profile 的依赖树能解析模块名
99
+ (`@morlay/dsh-preset` 已在 `dependencies` 声明本包)。patch 层级的合并顺序与放置理由见
100
+ [设计 预设生成与装配](../../preset/dsh-preset/.agents/designs/20260917-预设生成与装配.md)。
113
101
  两种采用方式互斥:同时上线会重复插入同一行。
114
102
 
115
- **规则写在哪里才会生效**(patch 层按 `[bundle patches, profile patches, home patches,
116
- overlays]` 合并,后应用者整块替换同一行的 `config`):
117
-
118
- | 载体 | 生效范围 |
119
- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
120
- | profile 的 `cordis.patch.yml`(`$DSH_HOME/profiles/<name>/cordis.patch.yml`) | dev 与打包形态都生效,但只属于本机 home |
121
- | app 的 `cordis.patch.yml` | 打包(`bundle`)形态:作为 seed 的 profile patch 生效 |
122
- | 一个自有 bundle 的 patch | 所有形态(随包分发) |
123
-
124
- 本部署采用最后一种:**装配与规则在 `@morlay/dsh-preset` 的 bundle patch 里一起维护**
125
- (禁用官方两行 + 插入本包行 + `access` 规则,跨 dev / 打包形态一致),不依赖 app 的
126
- `dsh.profile.bundles` 再列一层。
127
-
128
103
  ## 前提
129
104
 
130
105
  - 官方 `sandbox` 与 `fs-sandbox` 行必须禁用:同一 scope 内重复注册同名服务会 fail loud
@@ -154,7 +129,7 @@ overlays]` 合并,后应用者整块替换同一行的 `config`):
154
129
  - **Windows 额外授权未实现**:官方 `AclWriteGrant` 可以做预授权,但没有把 `AclWriteGrant`
155
130
  接进 `confine` 的现成路径,本版只告警。
156
131
 
157
- ## 本地开发
132
+ ## 验证
158
133
 
159
- 根目录 `just test`(vitest,含 `seatbelt.e2e.spec.ts`——非 macOS 或被更外层 Seatbelt
160
- 拦住时自动跳过)、`just lint`(oxlint typeAware)、`just build`(tsdown 构建本包)。
134
+ 构建、测试与 lint 走根 `justfile`(含 `seatbelt.e2e.spec.ts`——非 macOS 或被更外层 Seatbelt
135
+ 拦住时自动跳过);本包的接缝与判据见 [`.agents/standards/`](./.agents/standards/)。
package/dist/index.d.mts CHANGED
@@ -3,27 +3,14 @@ import { Config as Config$2 } from "@deepseek-ai/dsh-sandbox-local";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { Context } from "@deepseek-ai/cordis";
5
5
  //#region src/config.d.ts
6
- /** 插件配置。 */
7
6
  interface Config extends Config$2, Config$1 {
8
- /**
9
- * 规则条目:`rw <path>`(额外可写根)、`r- <path>`(只读)、`-- <pattern>`(拒绝访问)。
10
- * 数组每项一条,或写一段多行文本(每行一条);`{{ env.NAME }}` 按进程环境展开。
11
- */
12
7
  access?: string | string[];
13
8
  }
14
- /** 运行时配置 schema。 */
15
9
  declare const Config: z<Config>;
16
10
  //#endregion
17
11
  //#region src/index.d.ts
18
- /** Cordis 插件名。 */
19
12
  declare const name = "sandbox-local";
20
- /** fs 侧从策略服务取默认模式与工作区回退根,所以先等 `ctx.sandboxPolicy`。 */
21
13
  declare const inject: string[];
22
- /**
23
- * 注册两个替换实现。
24
- * @param ctx - 插件上下文(官方 `sandbox` / `fs-sandbox` 行已禁用)。
25
- * @param config - 已由 schema 填好默认值的配置。
26
- */
27
14
  declare function apply(ctx: Context, config: Config): void;
28
15
  //#endregion
29
16
  export { Config, apply, inject, name };
package/dist/index.mjs CHANGED
@@ -6,26 +6,10 @@ import { stat } from "node:fs/promises";
6
6
  import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
7
7
  import z from "@deepseek-ai/schemastery";
8
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
9
  const ENV_TEMPLATE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
22
- /** `access` 的条目形式:`rw <path>` / `r- <path>` / `-- <pattern>`;前缀后必须有空白。 */
23
10
  const ACCESS_LINE = /^(rw|r-|--)(?:\s+(.*))?$/u;
24
- /** glob 元字符:规则里出现即按模式匹配,否则按字面路径(含其全部后代)匹配。 */
25
11
  const GLOB_META = /[*?[]/;
26
- /** glob 翻译时需要转义的正则元字符(字符类内部除外)。 */
27
12
  const REGEX_META = /[\\^$+.(){}|]/;
28
- /** 展开规则里的 `{{ env.NAME }}`;引用未定义或为空的环境变量直接抛错。 */
29
13
  function expandEnvTemplates(value, env) {
30
14
  return value.replace(ENV_TEMPLATE, (_match, name) => {
31
15
  const resolved = env[name];
@@ -33,13 +17,6 @@ function expandEnvTemplates(value, env) {
33
17
  return resolved;
34
18
  });
35
19
  }
36
- /**
37
- * 把含 glob 的规则翻译成正则源码。
38
- * `**` 跨目录层级(`**` 与“`**` 后接斜杠”都能匹配零层),`*` 与 `?` 不跨 `/`,
39
- * 字符类透传(`[!…]` 按 glob 习惯翻成 `[^…]`),其余正则元字符转义。
40
- * @param pattern - 已绝对化的 glob 规则。
41
- * @returns 可同时被 JS `RegExp` 与 SBPL regex 接受的正则源码。
42
- */
43
20
  function globToRegexSource(pattern) {
44
21
  let source = "";
45
22
  for (let index = 0; index < pattern.length; index += 1) {
@@ -78,24 +55,15 @@ function globToRegexSource(pattern) {
78
55
  }
79
56
  return source;
80
57
  }
81
- /** 规则是否为空——空规则下 provider 不改写任何 runner 参数。 */
82
58
  function isEmptyRules(rules) {
83
59
  return rules.allowRoots.length === 0 && rules.readOnlySubtrees.length === 0 && rules.readOnlyPatterns.length === 0 && rules.denySubtrees.length === 0 && rules.denyPatterns.length === 0;
84
60
  }
85
- /** 只读模式不因 `rw` 条目放松:投影出只保留 `r-` / `--` 条目的规则集。 */
86
61
  function withoutAllowRoots(rules) {
87
62
  return rules.allowRoots.length === 0 ? rules : {
88
63
  ...rules,
89
64
  allowRoots: []
90
65
  };
91
66
  }
92
- /**
93
- * 解析 `access` 配置:接受字符串数组(每项一条规则)或多行文本(每行一条规则),
94
- * 空行忽略;`rw <path>` 是额外可写根,`r- <path>` 是只读,`-- <pattern>` 是访问
95
- * 拒绝(读 + 写)。缺前缀或前缀后没有路径都直接报错——规则不因写法歧义而变形。
96
- * @param input - 配置里的 `access` 值。
97
- * @returns `allowWrite` / `readOnly` / `deny` 三组规则(模板尚未展开)。
98
- */
99
67
  function parseAccess(input) {
100
68
  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
69
  const allowWrite = [];
@@ -116,13 +84,6 @@ function parseAccess(input) {
116
84
  deny
117
85
  };
118
86
  }
119
- /**
120
- * 把配置里的 `access` 转成规则来源:模板在这一步展开(加载期,fail-fast),
121
- * 相对路径留待按调用时的工作区根绝对化。
122
- * @param config - 含 `access` 的插件配置。
123
- * @param env - 模板展开用的进程环境。
124
- * @returns 模板已展开的规则来源。
125
- */
126
87
  function ruleSourceOf(config, env) {
127
88
  const parsed = parseAccess(config.access);
128
89
  const expand = (values) => values.map((value) => expandEnvTemplates(value, env));
@@ -132,11 +93,9 @@ function ruleSourceOf(config, env) {
132
93
  deny: expand(parsed.deny)
133
94
  };
134
95
  }
135
- /** 绝对化一条规则:相对路径相对工作区根。 */
136
96
  function absolutize(value, workspaceRoot) {
137
97
  return isAbsolute(value) ? value : resolve(workspaceRoot, value);
138
98
  }
139
- /** 编译一组字面路径 / glob 规则:字面项按子树(含全部后代)匹配,glob 项按整串匹配。 */
140
99
  function compilePaths(values, workspaceRoot) {
141
100
  const subtrees = [];
142
101
  const patterns = [];
@@ -155,13 +114,6 @@ function compilePaths(values, workspaceRoot) {
155
114
  patterns
156
115
  };
157
116
  }
158
- /**
159
- * 按一个工作区根编译规则。
160
- * `rw` 条目必须是具体路径(glob 无法表达“可写根”);`r-` 与 `--` 条目允许 glob。
161
- * @param source - 已展开模板的规则来源。
162
- * @param workspaceRoot - 相对规则解析用的工作区根(canonical 绝对路径)。
163
- * @returns 编译后的规则。
164
- */
165
117
  function compileRules(source, workspaceRoot) {
166
118
  const allowRoots = [];
167
119
  for (const value of source.allowWrite) {
@@ -178,50 +130,23 @@ function compileRules(source, workspaceRoot) {
178
130
  denyPatterns: deny.patterns
179
131
  };
180
132
  }
181
- /** 字面规则的后代判定(canonical 拼写,与 Seatbelt `subpath` 同一语义)。 */
182
133
  function isUnder(path, root) {
183
134
  if (path === root) return true;
184
135
  return path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
185
136
  }
186
- /** 命中任一子树或任一模式。 */
187
137
  function matches(canonicalTarget, subtrees, patterns) {
188
138
  for (const subtree of subtrees) if (isUnder(canonicalTarget, subtree)) return true;
189
139
  return patterns.some((pattern) => pattern.regex.test(canonicalTarget));
190
140
  }
191
- /**
192
- * 判断目标是否被 `--` 条目命中(读与写都拒)。
193
- * @param rules - 已编译规则。
194
- * @param canonicalTarget - 目标的 canonical 路径。
195
- * @returns 命中即 true。
196
- */
197
141
  function isDenied(rules, canonicalTarget) {
198
142
  return matches(canonicalTarget, rules.denySubtrees, rules.denyPatterns);
199
143
  }
200
- /**
201
- * 判断目标是否被 `r-` 条目命中(读放行、写拒绝)。
202
- * @param rules - 已编译规则。
203
- * @param canonicalTarget - 目标的 canonical 路径。
204
- * @returns 命中即 true。
205
- */
206
144
  function isReadOnly(rules, canonicalTarget) {
207
145
  return matches(canonicalTarget, rules.readOnlySubtrees, rules.readOnlyPatterns);
208
146
  }
209
147
  //#endregion
210
148
  //#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
149
  const SEPARATOR = "--";
224
- /** 各方言的规则表达能力——加载期据此告警,运行期据此决定是否改写参数。 */
225
150
  const DIALECT_CAPABILITIES = {
226
151
  seatbelt: {
227
152
  allowWrite: true,
@@ -248,17 +173,11 @@ const DIALECT_CAPABILITIES = {
248
173
  denyWriteOnly: false
249
174
  }
250
175
  };
251
- /** runner 部分的结束位置。 */
252
176
  function separatorIndex(argv) {
253
177
  const index = argv.indexOf(SEPARATOR);
254
178
  if (index === -1) throw new Error("sandbox rules: the confined argv carries no `--` separator to extend");
255
179
  return index;
256
180
  }
257
- /**
258
- * 识别 argv 使用的执行方言。
259
- * @param argv - 官方 provider 返回的完整 confined argv。
260
- * @returns 方言,或无法识别时的 `undefined`(例如运维自定义的 runnerCommand)。
261
- */
262
181
  function dialectOf(argv) {
263
182
  const separator = argv.indexOf(SEPARATOR);
264
183
  const runner = separator === -1 ? argv : argv.slice(0, separator);
@@ -267,19 +186,13 @@ function dialectOf(argv) {
267
186
  if (runner.includes("--workspace")) return "windows-acl";
268
187
  if (runner.includes("--ro") || runner.includes("--rw")) return "landlock";
269
188
  }
270
- /** SBPL 字符串字面量。 */
271
189
  function sbplString(value) {
272
190
  return `"${value.replaceAll("\\", String.raw`\\`).replaceAll("\"", String.raw`\"`)}"`;
273
191
  }
274
- /**
275
- * SBPL `#"…"` 的正则体:正则自身的 `\` 必须保留(glob 翻译用它转义元字符),
276
- * 因此拒绝项里出现双引号时直接报错,而不是产出一个含义变化的 profile。
277
- */
278
192
  function sbplRegexBody(source) {
279
193
  if (source.includes("\"")) throw new Error(`sandbox rules: deny pattern ${JSON.stringify(source)} cannot be expressed in a Seatbelt profile`);
280
194
  return source;
281
195
  }
282
- /** Seatbelt:把 allow / deny 规则追加到 `-p` 的 profile 文本末尾。 */
283
196
  function extendSeatbelt(argv, rules) {
284
197
  const index = argv.indexOf("-p");
285
198
  const profile = index === -1 ? void 0 : argv[index + 1];
@@ -297,11 +210,6 @@ function extendSeatbelt(argv, rules) {
297
210
  ...argv.slice(index + 2)
298
211
  ];
299
212
  }
300
- /**
301
- * bwrap:额外可写根用 `--bind-try`(路径不存在时跳过);`r-` 与 `--` 都用
302
- * `--ro-bind-try` 覆盖成只读(挂载后行覆盖前行)——也就是说 `--` 在 bwrap 上退化为
303
- * “只拒写入”。glob 模式无法表达为静态挂载,由调用方在加载期告警。
304
- */
305
213
  function extendBwrap(argv, rules) {
306
214
  const separator = separatorIndex(argv);
307
215
  const additions = [];
@@ -313,7 +221,6 @@ function extendBwrap(argv, rules) {
313
221
  ...argv.slice(separator)
314
222
  ];
315
223
  }
316
- /** Landlock:只能追加可写授权(`--rw`),`r-` 与 `--` 都无对应表达。 */
317
224
  function extendLandlock(argv, rules) {
318
225
  const separator = separatorIndex(argv);
319
226
  const additions = rules.allowRoots.flatMap((root) => ["--rw", root]);
@@ -323,12 +230,6 @@ function extendLandlock(argv, rules) {
323
230
  ...argv.slice(separator)
324
231
  ];
325
232
  }
326
- /**
327
- * 按 argv 的方言追加规则。
328
- * @param argv - 官方 provider 返回的 confined argv。
329
- * @param rules - 已编译规则;空规则原样返回。
330
- * @returns 追加规则后的 argv;方言无法识别时抛错(规则不能静默失效)。
331
- */
332
233
  function extendConfinedArgv(argv, rules) {
333
234
  if (isEmptyRules(rules)) return [...argv];
334
235
  const dialect = dialectOf(argv);
@@ -342,9 +243,7 @@ function extendConfinedArgv(argv, rules) {
342
243
  }
343
244
  //#endregion
344
245
  //#region src/containment.ts
345
- /** 视为“路径不存在”的错误码:只有它们能让祖先遍历继续。 */
346
246
  const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
347
- /** stat 一次,缺失返回 undefined;其它失败(权限、I/O)继续抛出。 */
348
247
  async function statIfPresent(path) {
349
248
  try {
350
249
  return await stat(path, { bigint: true });
@@ -354,11 +253,9 @@ async function statIfPresent(path) {
354
253
  throw error;
355
254
  }
356
255
  }
357
- /** 按大小写敏感性归一用于比较的拼写。 */
358
256
  function comparablePath(path, caseSensitive) {
359
257
  return caseSensitive ? path : path.toLowerCase();
360
258
  }
361
- /** 词法包含判定(目标可带尚不存在的后缀)。 */
362
259
  function isLexicallyUnder(path, root, caseSensitive) {
363
260
  const comparableTarget = comparablePath(path, caseSensitive);
364
261
  const comparableRoot = comparablePath(root, caseSensitive);
@@ -366,19 +263,9 @@ function isLexicallyUnder(path, root, caseSensitive) {
366
263
  const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
367
264
  return comparableTarget.startsWith(prefix);
368
265
  }
369
- /** 两次 stat 是否指向同一个文件系统对象。 */
370
266
  function sameIdentity(left, right) {
371
267
  return left.dev === right.dev && left.ino === right.ino;
372
268
  }
373
- /**
374
- * 判断 canonical 目标是否就是某个根或位于其下。
375
- * 拼写不同时(Windows 长名/8.3 别名、大小写差异)沿目标的现存祖先比较文件系统身份,
376
- * 不把包含判定弱化成文本近似。
377
- * @param path - 目标的 canonical 路径(可带尚不存在的尾部)。
378
- * @param root - canonical 可写根。
379
- * @param caseSensitive - 词法比较是否区分大小写;默认按宿主平台约定。
380
- * @returns 目标是该根或其后代时为 true。
381
- */
382
269
  async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
383
270
  if (isLexicallyUnder(path, root, caseSensitive)) return true;
384
271
  const rootInfo = await statIfPresent(root);
@@ -394,78 +281,39 @@ async function isPathUnder(path, root, caseSensitive = process.platform !== "win
394
281
  }
395
282
  //#endregion
396
283
  //#region src/fs.ts
397
- /**
398
- * 官方本机文件系统后端的可配置版本。
399
- * `--` 条目在解析阶段拒绝目标(覆盖 read / write / edit / list 等一切工具入口),
400
- * `r-` 与 `--` 条目在写入前拒绝写入(优先于任何可写根),写操作再按
401
- * `workspace-write` + `rw` 条目复核 containment。
402
- */
403
284
  var ConfigurableFileSystem = class extends LocalFileSystem {
404
285
  static inject = ["sandboxPolicy"];
405
286
  defaultMode;
406
287
  source;
407
- /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
408
288
  compiled = /* @__PURE__ */ new Map();
409
289
  constructor(ctx, config) {
410
290
  super(ctx, config);
411
291
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
412
292
  this.source = ruleSourceOf(config, process.env);
413
293
  }
414
- /** 工具层读它判断后端是否 confine(并据此广告 escalation 字段)。 */
415
294
  get sandboxMode() {
416
295
  return this.defaultMode;
417
296
  }
418
- /**
419
- * 解析目标后立即执行拒绝判定:工具入口(read / write / edit / list)都先经过
420
- * `resolve`,因此一次判定即可覆盖读与写。
421
- * @param path - 待解析的路径。
422
- * @param opts - cwd 与取消信号;cwd 同时是相对规则的解析根。
423
- * @returns 解析后的目标。
424
- */
425
297
  async resolve(path, opts) {
426
298
  const target = await super.resolve(path, opts);
427
299
  this.assertNotDenied(this.rulesFor(opts?.cwd ?? this.ctx.sandboxPolicy.workspaceRoot), target.targetKey, target.displayPath);
428
300
  return target;
429
301
  }
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
302
  async writeText(target, content, expected, signal, sandboxPolicy) {
440
303
  return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal);
441
304
  }
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
305
  async editText(target, edit, expected, signal, sandboxPolicy) {
452
306
  return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal);
453
307
  }
454
- /** `--` 条目命中即拒绝访问(读与写都拒);抛 `FS_SANDBOX_DENIED`。 */
455
308
  assertNotDenied(rules, canonicalTarget, displayPath) {
456
309
  if (!isDenied(rules, canonicalTarget)) return;
457
310
  throw new FsError(`cannot access "${displayPath}": file access denied by the configured "--" rules`, "FS_SANDBOX_DENIED");
458
311
  }
459
- /** `--` 或 `r-` 条目命中即拒绝写入,且优先于任何可写根。 */
460
312
  assertWritable(rules, canonicalTarget, displayPath) {
461
313
  this.assertNotDenied(rules, canonicalTarget, displayPath);
462
314
  if (!isReadOnly(rules, canonicalTarget)) return;
463
315
  throw new FsError(`cannot write "${displayPath}": path is read-only by the configured "r-" rule`, "FS_SANDBOX_DENIED");
464
316
  }
465
- /**
466
- * 写前复核:`--` / `r-` 条目(任何模式)→ 模式本身的只读拒绝 → `workspace-write` 的
467
- * `writableRoots + rw` containment,返回必须被写入的那个目标。
468
- */
469
317
  async checkedTarget(target, sandboxPolicy) {
470
318
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
471
319
  const rules = this.rulesFor(policy.workspaceRoot);
@@ -478,7 +326,6 @@ var ConfigurableFileSystem = class extends LocalFileSystem {
478
326
  for (const root of [...writableRoots(policy), ...rules.allowRoots]) if (await isPathUnder(fresh.targetKey, root)) return fresh;
479
327
  throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
480
328
  }
481
- /** 取(并按需编译缓存)某个工作区根下的规则。 */
482
329
  rulesFor(workspaceRoot) {
483
330
  const cached = this.compiled.get(workspaceRoot);
484
331
  if (cached !== void 0) return cached;
@@ -489,27 +336,15 @@ var ConfigurableFileSystem = class extends LocalFileSystem {
489
336
  };
490
337
  //#endregion
491
338
  //#region src/sandbox.ts
492
- /**
493
- * 官方本机沙箱 provider 的可配置版本。
494
- * `read-only` 模式不追加 `rw` 条目(只读边界不因额外可写根放松);`--` 条目在两种
495
- * confined 模式下都生效。
496
- */
497
339
  var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
498
340
  source;
499
- /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
500
341
  compiled = /* @__PURE__ */ new Map();
501
342
  constructor(ctx, config) {
502
343
  super(ctx, config);
503
344
  this.source = ruleSourceOf(config, process.env);
504
345
  }
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);
346
+ async confine(argv, policy, signal) {
347
+ const confined = await super.confine(argv, policy, signal);
513
348
  const rules = this.rulesFor(policy.workspaceRoot);
514
349
  const effective = policy.mode === "workspace-write" ? rules : withoutAllowRoots(rules);
515
350
  if (isEmptyRules(effective)) return confined;
@@ -518,7 +353,6 @@ var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
518
353
  argv: extendConfinedArgv(confined.argv, effective)
519
354
  };
520
355
  }
521
- /** 取(并按需编译缓存)某个工作区根下的规则。 */
522
356
  rulesFor(workspaceRoot) {
523
357
  const cached = this.compiled.get(workspaceRoot);
524
358
  if (cached !== void 0) return cached;
@@ -529,15 +363,6 @@ var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
529
363
  };
530
364
  //#endregion
531
365
  //#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
366
  const Config = z.object({
542
367
  access: z.union([z.array(z.string()), z.string()]).default([]),
543
368
  runnerCommand: z.array(z.string()).default([]),
@@ -548,15 +373,8 @@ const Config = z.object({
548
373
  });
549
374
  //#endregion
550
375
  //#region src/index.ts
551
- /** Cordis 插件名。 */
552
376
  const name = "sandbox-local";
553
- /** fs 侧从策略服务取默认模式与工作区回退根,所以先等 `ctx.sandboxPolicy`。 */
554
377
  const inject = ["sandboxPolicy"];
555
- /**
556
- * 规则在进程沙箱侧的能力随平台方言变化,加载期把降级说清楚:
557
- * `ctx.fs` 侧(read / write / edit 工具)在 macOS / Linux / Windows 上语义一致,
558
- * 只有 bash 等子进程走平台 runner 的表达能力。
559
- */
560
378
  function warnAboutDegradedRules(ctx, config) {
561
379
  const rules = ruleSourceOf(config, process.env);
562
380
  const grants = rules.allowWrite.length > 0;
@@ -571,11 +389,6 @@ function warnAboutDegradedRules(ctx, config) {
571
389
  }
572
390
  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
391
  }
574
- /**
575
- * 注册两个替换实现。
576
- * @param ctx - 插件上下文(官方 `sandbox` / `fs-sandbox` 行已禁用)。
577
- * @param config - 已由 schema 填好默认值的配置。
578
- */
579
392
  function apply(ctx, config) {
580
393
  warnAboutDegradedRules(ctx, config);
581
394
  new ConfigurableSandboxProvider(ctx, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/dsh-sandbox-local",
3
- "version": "0.0.2",
3
+ "version": "0.0.3-alpha.0",
4
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
5
  "keywords": [
6
6
  "dsh",
@@ -12,7 +12,7 @@
12
12
  "license": "MIT",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/morlay/better-session.git"
15
+ "url": "https://github.com/morlay/dsh-plugin.git"
16
16
  },
17
17
  "files": [
18
18
  "dist",
@@ -28,21 +28,21 @@
28
28
  },
29
29
  "devDependencies": {
30
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"
31
+ "@deepseek-ai/dsh-fs": "^0.1.6-alpha.2",
32
+ "@deepseek-ai/dsh-fs-local": "^0.1.6-alpha.2",
33
+ "@deepseek-ai/dsh-fs-sandbox": "^0.1.6-alpha.2",
34
+ "@deepseek-ai/dsh-sandbox": "^0.1.6-alpha.2",
35
+ "@deepseek-ai/dsh-sandbox-local": "^0.1.6-alpha.2",
36
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.6-alpha.2"
37
37
  },
38
38
  "peerDependencies": {
39
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",
40
+ "@deepseek-ai/dsh-fs": "^0.1.6-alpha.2",
41
+ "@deepseek-ai/dsh-fs-local": "^0.1.6-alpha.2",
42
+ "@deepseek-ai/dsh-fs-sandbox": "^0.1.6-alpha.2",
43
+ "@deepseek-ai/dsh-sandbox": "^0.1.6-alpha.2",
44
+ "@deepseek-ai/dsh-sandbox-local": "^0.1.6-alpha.2",
45
+ "@deepseek-ai/dsh-sandbox-policy": "^0.1.6-alpha.2",
46
46
  "@deepseek-ai/schemastery": "^3.18.2"
47
47
  },
48
48
  "dsh": {
package/src/config.ts CHANGED
@@ -1,26 +1,11 @@
1
- /**
2
- * 插件配置的单一真源:`access` 规则 + 透传官方的字段。
3
- *
4
- * 规则只在这里声明一次——provider 与 fs 后端按构造参数接收已解析的配置,
5
- * 不各自重复声明 schema;部署层的规则值随插入本行的 patch 写死(本部署见
6
- * `@morlay/dsh-preset`),包内不预设。
7
- * @module @morlay/dsh-sandbox-local/config
8
- */
9
-
10
1
  import z from "@deepseek-ai/schemastery";
11
2
  import type { Config as UpstreamFsConfig } from "@deepseek-ai/dsh-fs-local";
12
3
  import type { Config as UpstreamSandboxConfig } from "@deepseek-ai/dsh-sandbox-local";
13
4
 
14
- /** 插件配置。 */
15
5
  export interface Config extends UpstreamSandboxConfig, UpstreamFsConfig {
16
- /**
17
- * 规则条目:`rw <path>`(额外可写根)、`r- <path>`(只读)、`-- <pattern>`(拒绝访问)。
18
- * 数组每项一条,或写一段多行文本(每行一条);`{{ env.NAME }}` 按进程环境展开。
19
- */
20
6
  access?: string | string[];
21
7
  }
22
8
 
23
- /** 运行时配置 schema。 */
24
9
  export const Config: z<Config> = z.object({
25
10
  access: z.union([z.array(z.string()), z.string()]).default([]),
26
11
  runnerCommand: z.array(z.string()).default([]),
@@ -1,20 +1,9 @@
1
- /**
2
- * 目标是否位于某个可写根之下的判定。
3
- *
4
- * 语义与上游 `@deepseek-ai/dsh-fs-sandbox/src/containment.ts` 对齐(canonical 拼写走
5
- * 词法快路径,别名/大小写差异由文件系统身份兜底);本包自带一份,是因为该模块只存在于
6
- * 上游包的 `src/` 深处,而发布产物(`lib/`)与安装态 profile 的模块解析都不覆盖深路径。
7
- * @module @morlay/dsh-sandbox-local/containment
8
- */
9
-
10
1
  import type { BigIntStats } from "node:fs";
11
2
  import { stat } from "node:fs/promises";
12
3
  import { dirname, sep } from "node:path";
13
4
 
14
- /** 视为“路径不存在”的错误码:只有它们能让祖先遍历继续。 */
15
5
  const MISSING_CODES: ReadonlySet<string> = new Set(["ENOENT", "ENOTDIR"]);
16
6
 
17
- /** stat 一次,缺失返回 undefined;其它失败(权限、I/O)继续抛出。 */
18
7
  async function statIfPresent(path: string): Promise<BigIntStats | undefined> {
19
8
  try {
20
9
  return await stat(path, { bigint: true });
@@ -25,12 +14,10 @@ async function statIfPresent(path: string): Promise<BigIntStats | undefined> {
25
14
  }
26
15
  }
27
16
 
28
- /** 按大小写敏感性归一用于比较的拼写。 */
29
17
  function comparablePath(path: string, caseSensitive: boolean): string {
30
18
  return caseSensitive ? path : path.toLowerCase();
31
19
  }
32
20
 
33
- /** 词法包含判定(目标可带尚不存在的后缀)。 */
34
21
  function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean {
35
22
  const comparableTarget = comparablePath(path, caseSensitive);
36
23
  const comparableRoot = comparablePath(root, caseSensitive);
@@ -39,20 +26,10 @@ function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): b
39
26
  return comparableTarget.startsWith(prefix);
40
27
  }
41
28
 
42
- /** 两次 stat 是否指向同一个文件系统对象。 */
43
29
  function sameIdentity(left: BigIntStats, right: BigIntStats): boolean {
44
30
  return left.dev === right.dev && left.ino === right.ino;
45
31
  }
46
32
 
47
- /**
48
- * 判断 canonical 目标是否就是某个根或位于其下。
49
- * 拼写不同时(Windows 长名/8.3 别名、大小写差异)沿目标的现存祖先比较文件系统身份,
50
- * 不把包含判定弱化成文本近似。
51
- * @param path - 目标的 canonical 路径(可带尚不存在的尾部)。
52
- * @param root - canonical 可写根。
53
- * @param caseSensitive - 词法比较是否区分大小写;默认按宿主平台约定。
54
- * @returns 目标是该根或其后代时为 true。
55
- */
56
33
  export async function isPathUnder(
57
34
  path: string,
58
35
  root: string,
package/src/dialects.ts CHANGED
@@ -1,40 +1,19 @@
1
- /**
2
- * 在官方 provider 生成的 confined argv 上追加本包规则:
3
- * Seatbelt 追加 SBPL 规则(后置规则覆盖先前的 allow,实测 `(deny file-read* file-write*
4
- * (subpath …))` 能压过 `(allow file-write* (subpath …))`,`r-` 条目则只 deny 写入),
5
- * bwrap 追加挂载参数(`rw` 用 `--bind-try`,`r-` 与 `--` 都用 `--ro-bind-try`),
6
- * Landlock 只能追加可写授权(其 allow-list 语义无法减除子路径),
7
- * Windows ACL runner 没有对应表达。
8
- *
9
- * 方言从官方 `ConfinedArgv.argv` 的结构识别:runner 参数在前,`--` 之后是调用方 argv。
10
- * @module @morlay/dsh-sandbox-local/dialects
11
- */
12
-
13
1
  import { isEmptyRules, type CompiledRules } from "./rules.ts";
14
2
 
15
- /** 官方 provider 可选的执行方言。 */
16
3
  export type SandboxDialect = "seatbelt" | "bwrap" | "landlock" | "windows-acl";
17
4
 
18
- /** runner 部分与调用方 argv 的分隔符。 */
19
5
  const SEPARATOR = "--";
20
6
 
21
- /**
22
- * 一个方言能表达的规则能力。
23
- * `readOnly` 指 `r-` 条目(只拒写入);`denyWriteOnly` 指 `--` 条目在该方言上只能
24
- * 退化为“只拒写入”(读取仍放行)。
25
- */
26
7
  export interface DialectCapabilities {
27
- /** 能否把额外可写根写进 runner 参数。 */
28
8
  readonly allowWrite: boolean;
29
- /** 能否表达 `r-`(只拒写入)。 */
9
+
30
10
  readonly readOnly: boolean;
31
- /** 能否表达 `--` 的完整语义(读与写都拒)。 */
11
+
32
12
  readonly denyReadWrite: boolean;
33
- /** `--` 是否只能退化为拒绝写入。 */
13
+
34
14
  readonly denyWriteOnly: boolean;
35
15
  }
36
16
 
37
- /** 各方言的规则表达能力——加载期据此告警,运行期据此决定是否改写参数。 */
38
17
  export const DIALECT_CAPABILITIES: Record<SandboxDialect, DialectCapabilities> = {
39
18
  seatbelt: { allowWrite: true, readOnly: true, denyReadWrite: true, denyWriteOnly: false },
40
19
  bwrap: { allowWrite: true, readOnly: true, denyReadWrite: false, denyWriteOnly: true },
@@ -42,7 +21,6 @@ export const DIALECT_CAPABILITIES: Record<SandboxDialect, DialectCapabilities> =
42
21
  "windows-acl": { allowWrite: false, readOnly: false, denyReadWrite: false, denyWriteOnly: false },
43
22
  };
44
23
 
45
- /** runner 部分的结束位置。 */
46
24
  function separatorIndex(argv: readonly string[]): number {
47
25
  const index = argv.indexOf(SEPARATOR);
48
26
  if (index === -1) {
@@ -51,11 +29,6 @@ function separatorIndex(argv: readonly string[]): number {
51
29
  return index;
52
30
  }
53
31
 
54
- /**
55
- * 识别 argv 使用的执行方言。
56
- * @param argv - 官方 provider 返回的完整 confined argv。
57
- * @returns 方言,或无法识别时的 `undefined`(例如运维自定义的 runnerCommand)。
58
- */
59
32
  export function dialectOf(argv: readonly string[]): SandboxDialect | undefined {
60
33
  const separator = argv.indexOf(SEPARATOR);
61
34
  const runner = separator === -1 ? argv : argv.slice(0, separator);
@@ -66,15 +39,10 @@ export function dialectOf(argv: readonly string[]): SandboxDialect | undefined {
66
39
  return undefined;
67
40
  }
68
41
 
69
- /** SBPL 字符串字面量。 */
70
42
  function sbplString(value: string): string {
71
43
  return `"${value.replaceAll("\\", String.raw`\\`).replaceAll('"', String.raw`\"`)}"`;
72
44
  }
73
45
 
74
- /**
75
- * SBPL `#"…"` 的正则体:正则自身的 `\` 必须保留(glob 翻译用它转义元字符),
76
- * 因此拒绝项里出现双引号时直接报错,而不是产出一个含义变化的 profile。
77
- */
78
46
  function sbplRegexBody(source: string): string {
79
47
  if (source.includes('"')) {
80
48
  throw new Error(
@@ -84,7 +52,6 @@ function sbplRegexBody(source: string): string {
84
52
  return source;
85
53
  }
86
54
 
87
- /** Seatbelt:把 allow / deny 规则追加到 `-p` 的 profile 文本末尾。 */
88
55
  function extendSeatbelt(argv: readonly string[], rules: CompiledRules): string[] {
89
56
  const index = argv.indexOf("-p");
90
57
  const profile = index === -1 ? undefined : argv[index + 1];
@@ -108,11 +75,6 @@ function extendSeatbelt(argv: readonly string[], rules: CompiledRules): string[]
108
75
  return [...argv.slice(0, index + 1), extended, ...argv.slice(index + 2)];
109
76
  }
110
77
 
111
- /**
112
- * bwrap:额外可写根用 `--bind-try`(路径不存在时跳过);`r-` 与 `--` 都用
113
- * `--ro-bind-try` 覆盖成只读(挂载后行覆盖前行)——也就是说 `--` 在 bwrap 上退化为
114
- * “只拒写入”。glob 模式无法表达为静态挂载,由调用方在加载期告警。
115
- */
116
78
  function extendBwrap(argv: readonly string[], rules: CompiledRules): string[] {
117
79
  const separator = separatorIndex(argv);
118
80
  const additions: string[] = [];
@@ -123,19 +85,12 @@ function extendBwrap(argv: readonly string[], rules: CompiledRules): string[] {
123
85
  return [...argv.slice(0, separator), ...additions, ...argv.slice(separator)];
124
86
  }
125
87
 
126
- /** Landlock:只能追加可写授权(`--rw`),`r-` 与 `--` 都无对应表达。 */
127
88
  function extendLandlock(argv: readonly string[], rules: CompiledRules): string[] {
128
89
  const separator = separatorIndex(argv);
129
90
  const additions = rules.allowRoots.flatMap((root) => ["--rw", root]);
130
91
  return [...argv.slice(0, separator), ...additions, ...argv.slice(separator)];
131
92
  }
132
93
 
133
- /**
134
- * 按 argv 的方言追加规则。
135
- * @param argv - 官方 provider 返回的 confined argv。
136
- * @param rules - 已编译规则;空规则原样返回。
137
- * @returns 追加规则后的 argv;方言无法识别时抛错(规则不能静默失效)。
138
- */
139
94
  export function extendConfinedArgv(argv: readonly string[], rules: CompiledRules): string[] {
140
95
  if (isEmptyRules(rules)) return [...argv];
141
96
  const dialect = dialectOf(argv);
@@ -152,7 +107,6 @@ export function extendConfinedArgv(argv: readonly string[], rules: CompiledRules
152
107
  case "landlock":
153
108
  return extendLandlock(argv, rules);
154
109
  case "windows-acl":
155
- // windows-acl 的 runner 参数没有承载额外 grant 的入口:加载期已告警,这里保持原 argv。
156
110
  return [...argv];
157
111
  }
158
112
  }
package/src/fs.ts CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * 文件系统围栏:继承官方 `@deepseek-ai/dsh-fs-local` 的文本存储机制
3
- * (resolve / stat / 读流 / 原子写 / read-match-write 编辑),在访问入口叠加规则:
4
- * `--` 命中即拒绝访问(读与写都拒,任何模式下都生效),`r-` 只拒写入,`rw` 参与
5
- * `workspace-write` 的可写判定(官方 `writableRoots` 之外的额外可写根)。
6
- *
7
- * 与它替换掉的官方 `@deepseek-ai/dsh-fs-sandbox` 一样,这是受信代码里的策略检查,
8
- * 不是内核边界:内核级隔离仍由 `ctx.sandbox` 侧负责。
9
- * @module @morlay/dsh-sandbox-local/fs
10
- */
11
-
12
1
  import type { Context } from "@deepseek-ai/cordis";
13
2
  import { FsError } from "@deepseek-ai/dsh-fs";
14
3
  import type {
@@ -34,18 +23,12 @@ import {
34
23
  type RuleSource,
35
24
  } from "./rules.ts";
36
25
 
37
- /**
38
- * 官方本机文件系统后端的可配置版本。
39
- * `--` 条目在解析阶段拒绝目标(覆盖 read / write / edit / list 等一切工具入口),
40
- * `r-` 与 `--` 条目在写入前拒绝写入(优先于任何可写根),写操作再按
41
- * `workspace-write` + `rw` 条目复核 containment。
42
- */
43
26
  export class ConfigurableFileSystem extends LocalFileSystem {
44
27
  static inject = ["sandboxPolicy"];
45
28
 
46
29
  private readonly defaultMode: SandboxMode;
47
30
  private readonly source: RuleSource;
48
- /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
31
+
49
32
  private readonly compiled = new Map<string, CompiledRules>();
50
33
 
51
34
  constructor(ctx: Context, config: Config) {
@@ -54,18 +37,10 @@ export class ConfigurableFileSystem extends LocalFileSystem {
54
37
  this.source = ruleSourceOf(config, process.env);
55
38
  }
56
39
 
57
- /** 工具层读它判断后端是否 confine(并据此广告 escalation 字段)。 */
58
40
  override get sandboxMode(): SandboxMode {
59
41
  return this.defaultMode;
60
42
  }
61
43
 
62
- /**
63
- * 解析目标后立即执行拒绝判定:工具入口(read / write / edit / list)都先经过
64
- * `resolve`,因此一次判定即可覆盖读与写。
65
- * @param path - 待解析的路径。
66
- * @param opts - cwd 与取消信号;cwd 同时是相对规则的解析根。
67
- * @returns 解析后的目标。
68
- */
69
44
  override async resolve(
70
45
  path: string,
71
46
  opts?: { cwd?: string; signal?: AbortSignal },
@@ -79,15 +54,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
79
54
  return target;
80
55
  }
81
56
 
82
- /**
83
- * 按 per-call 策略复核后写入。
84
- * @param target - 工具解析出的目标。
85
- * @param content - 新的完整内容。
86
- * @param expected - 写入前版本守卫。
87
- * @param signal - 取消信号。
88
- * @param sandboxPolicy - per-call 策略;省略时用部署默认。
89
- * @returns 上游写入结果。
90
- */
91
57
  override async writeText(
92
58
  target: FsTarget,
93
59
  content: string,
@@ -103,15 +69,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
103
69
  );
104
70
  }
105
71
 
106
- /**
107
- * 按 per-call 策略复核后编辑。
108
- * @param target - 工具解析出的目标。
109
- * @param edit - 字面量 search/replace 请求。
110
- * @param expected - 版本守卫。
111
- * @param signal - 取消信号。
112
- * @param sandboxPolicy - per-call 策略;省略时用部署默认。
113
- * @returns 上游编辑结果。
114
- */
115
72
  override async editText(
116
73
  target: FsTarget,
117
74
  edit: FsEditRequest,
@@ -122,7 +79,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
122
79
  return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal);
123
80
  }
124
81
 
125
- /** `--` 条目命中即拒绝访问(读与写都拒);抛 `FS_SANDBOX_DENIED`。 */
126
82
  private assertNotDenied(
127
83
  rules: CompiledRules,
128
84
  canonicalTarget: string,
@@ -135,7 +91,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
135
91
  );
136
92
  }
137
93
 
138
- /** `--` 或 `r-` 条目命中即拒绝写入,且优先于任何可写根。 */
139
94
  private assertWritable(rules: CompiledRules, canonicalTarget: string, displayPath: string): void {
140
95
  this.assertNotDenied(rules, canonicalTarget, displayPath);
141
96
  if (!isReadOnly(rules, canonicalTarget)) return;
@@ -145,10 +100,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
145
100
  );
146
101
  }
147
102
 
148
- /**
149
- * 写前复核:`--` / `r-` 条目(任何模式)→ 模式本身的只读拒绝 → `workspace-write` 的
150
- * `writableRoots + rw` containment,返回必须被写入的那个目标。
151
- */
152
103
  private async checkedTarget(
153
104
  target: FsTarget,
154
105
  sandboxPolicy?: SandboxExecutionPolicy,
@@ -164,8 +115,7 @@ export class ConfigurableFileSystem extends LocalFileSystem {
164
115
  "FS_SANDBOX_DENIED",
165
116
  );
166
117
  }
167
- // workspace-write:在新鲜解析出的 canonical 目标上复核,写入也用它(避免
168
- // “检查这里、写那里”的 TOCTOU 窗口)。
118
+
169
119
  const fresh = await super.resolve(target.displayPath);
170
120
  this.assertWritable(rules, fresh.targetKey, fresh.displayPath);
171
121
  for (const root of [...writableRoots(policy), ...rules.allowRoots]) {
@@ -177,7 +127,6 @@ export class ConfigurableFileSystem extends LocalFileSystem {
177
127
  );
178
128
  }
179
129
 
180
- /** 取(并按需编译缓存)某个工作区根下的规则。 */
181
130
  private rulesFor(workspaceRoot: string): CompiledRules {
182
131
  const cached = this.compiled.get(workspaceRoot);
183
132
  if (cached !== undefined) return cached;
package/src/index.ts CHANGED
@@ -1,19 +1,3 @@
1
- /**
2
- * 可配置沙箱插件:替换官方进程沙箱 provider(`ctx.sandbox`)与文件系统后端(`ctx.fs`),
3
- * 在官方语义之上叠加 `access` 规则。
4
- *
5
- * 每条规则以 `rw ` / `r- ` / `-- ` 开头:`rw <path>` 是工作区与平台临时目录之外的额外
6
- * 可写根,`r- <path>` 是只读(读放行、写拒绝),`-- <pattern>` 是访问拒绝(读 + 写)。
7
- * 三条都支持 `{{ env.NAME }}`(加载期展开,引用未定义的环境变量直接失败)与相对工作区的
8
- * 路径;命中优先级是 `--` > `r-` > 可写根。`ctx.fs` 侧语义完整,`ctx.sandbox` 侧在能表达
9
- * 该语义的方言上生效(见 {@link DIALECT_CAPABILITIES})。
10
- *
11
- * 装配前提:官方 `sandbox` 与 `fs-sandbox` 行必须被禁用(包内 `cordis.patch.yml` 或
12
- * 部署层 patch),否则同名服务 fail loud;规则由部署层在插入本行时写死
13
- * (本部署见 `@morlay/dsh-preset`),包内不预设。
14
- * @module @morlay/dsh-sandbox-local
15
- */
16
-
17
1
  import type { Context } from "@deepseek-ai/cordis";
18
2
  import type { Config } from "./config.ts";
19
3
  import { DIALECT_CAPABILITIES } from "./dialects.ts";
@@ -21,20 +5,12 @@ import { ConfigurableFileSystem } from "./fs.ts";
21
5
  import { ruleSourceOf } from "./rules.ts";
22
6
  import { ConfigurableSandboxProvider } from "./sandbox.ts";
23
7
 
24
- // 值与类型一起转出:Loader 读 `Config`(schema),消费方读类型。
25
8
  export { Config } from "./config.ts";
26
9
 
27
- /** Cordis 插件名。 */
28
10
  export const name = "sandbox-local";
29
11
 
30
- /** fs 侧从策略服务取默认模式与工作区回退根,所以先等 `ctx.sandboxPolicy`。 */
31
12
  export const inject = ["sandboxPolicy"];
32
13
 
33
- /**
34
- * 规则在进程沙箱侧的能力随平台方言变化,加载期把降级说清楚:
35
- * `ctx.fs` 侧(read / write / edit 工具)在 macOS / Linux / Windows 上语义一致,
36
- * 只有 bash 等子进程走平台 runner 的表达能力。
37
- */
38
14
  function warnAboutDegradedRules(ctx: Context, config: Config): void {
39
15
  const rules = ruleSourceOf(config, process.env);
40
16
  const grants = rules.allowWrite.length > 0;
@@ -60,11 +36,6 @@ function warnAboutDegradedRules(ctx: Context, config: Config): void {
60
36
  }
61
37
  }
62
38
 
63
- /**
64
- * 注册两个替换实现。
65
- * @param ctx - 插件上下文(官方 `sandbox` / `fs-sandbox` 行已禁用)。
66
- * @param config - 已由 schema 填好默认值的配置。
67
- */
68
39
  export function apply(ctx: Context, config: Config): void {
69
40
  warnAboutDegradedRules(ctx, config);
70
41
  new ConfigurableSandboxProvider(ctx, config);
package/src/rules.ts CHANGED
@@ -1,32 +1,15 @@
1
- /**
2
- * 规则编译:`access` 条目解析(`rw <path>` 额外可写根 / `r- <path>` 只读 /
3
- * `-- <pattern>` 拒绝访问)、`{{ env.NAME }}` 模板展开、相对工作区路径的绝对化、
4
- * glob → 正则,以及编译结果的匹配。
5
- *
6
- * 命中优先级:`--` 拒绝覆盖一切;`r-` 只读覆盖可写授予;都未命中时才按可写根判定。
7
- *
8
- * 生成的正则源码同时交给进程内检查(JS `RegExp`)与 macOS Seatbelt profile
9
- * (SBPL 的 `(regex #"…")`),因此只用 POSIX ERE 与 JS 正则共有的语法。
10
- * @module @morlay/dsh-sandbox-local/rules
11
- */
12
-
13
1
  import { isAbsolute, resolve, sep } from "node:path";
14
2
  import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
15
3
  import type { SandboxExecutionPolicy } from "@deepseek-ai/dsh-sandbox";
16
4
 
17
- /** `{{ env.NAME }}` 模板;名字限定为环境变量的字符集。 */
18
5
  const ENV_TEMPLATE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
19
6
 
20
- /** `access` 的条目形式:`rw <path>` / `r- <path>` / `-- <pattern>`;前缀后必须有空白。 */
21
7
  const ACCESS_LINE = /^(rw|r-|--)(?:\s+(.*))?$/u;
22
8
 
23
- /** glob 元字符:规则里出现即按模式匹配,否则按字面路径(含其全部后代)匹配。 */
24
9
  const GLOB_META = /[*?[]/;
25
10
 
26
- /** glob 翻译时需要转义的正则元字符(字符类内部除外)。 */
27
11
  const REGEX_META = /[\\^$+.(){}|]/;
28
12
 
29
- /** 展开规则里的 `{{ env.NAME }}`;引用未定义或为空的环境变量直接抛错。 */
30
13
  export function expandEnvTemplates(value: string, env: NodeJS.ProcessEnv): string {
31
14
  return value.replace(ENV_TEMPLATE, (_match, name: string) => {
32
15
  const resolved = env[name];
@@ -39,13 +22,6 @@ export function expandEnvTemplates(value: string, env: NodeJS.ProcessEnv): strin
39
22
  });
40
23
  }
41
24
 
42
- /**
43
- * 把含 glob 的规则翻译成正则源码。
44
- * `**` 跨目录层级(`**` 与“`**` 后接斜杠”都能匹配零层),`*` 与 `?` 不跨 `/`,
45
- * 字符类透传(`[!…]` 按 glob 习惯翻成 `[^…]`),其余正则元字符转义。
46
- * @param pattern - 已绝对化的 glob 规则。
47
- * @returns 可同时被 JS `RegExp` 与 SBPL regex 接受的正则源码。
48
- */
49
25
  export function globToRegexSource(pattern: string): string {
50
26
  let source = "";
51
27
  for (let index = 0; index < pattern.length; index += 1) {
@@ -89,39 +65,32 @@ export function globToRegexSource(pattern: string): string {
89
65
  return source;
90
66
  }
91
67
 
92
- /** 规则来源:模板已在加载期展开,路径仍是相对/绝对的原始拼写。 */
93
68
  export interface RuleSource {
94
- /** `rw` 条目:额外可写根。 */
95
69
  readonly allowWrite: readonly string[];
96
- /** `r-` 条目:只读(读放行、写拒绝)。 */
70
+
97
71
  readonly readOnly: readonly string[];
98
- /** `--` 条目:拒绝访问(读 + 写)。 */
72
+
99
73
  readonly deny: readonly string[];
100
74
  }
101
75
 
102
- /** 一条已编译的模式:源码同时供 JS `RegExp` 与 SBPL regex 使用。 */
103
76
  export interface CompiledPattern {
104
- /** 正则源码。 */
105
77
  readonly source: string;
106
- /** 进程内检查用的已编译正则。 */
78
+
107
79
  readonly regex: RegExp;
108
80
  }
109
81
 
110
- /** 编译后的规则:按一个工作区根绝对化后的可写根、只读项与拒绝项。 */
111
82
  export interface CompiledRules {
112
- /** 额外可写根(canonical 绝对路径)。 */
113
83
  readonly allowRoots: readonly string[];
114
- /** 只读的字面路径(canonical):命中自身及其全部后代。 */
84
+
115
85
  readonly readOnlySubtrees: readonly string[];
116
- /** 只读的模式。 */
86
+
117
87
  readonly readOnlyPatterns: readonly CompiledPattern[];
118
- /** 拒绝访问的字面路径(canonical):命中自身及其全部后代。 */
88
+
119
89
  readonly denySubtrees: readonly string[];
120
- /** 拒绝访问的模式。 */
90
+
121
91
  readonly denyPatterns: readonly CompiledPattern[];
122
92
  }
123
93
 
124
- /** 规则是否为空——空规则下 provider 不改写任何 runner 参数。 */
125
94
  export function isEmptyRules(rules: CompiledRules): boolean {
126
95
  return (
127
96
  rules.allowRoots.length === 0 &&
@@ -132,18 +101,10 @@ export function isEmptyRules(rules: CompiledRules): boolean {
132
101
  );
133
102
  }
134
103
 
135
- /** 只读模式不因 `rw` 条目放松:投影出只保留 `r-` / `--` 条目的规则集。 */
136
104
  export function withoutAllowRoots(rules: CompiledRules): CompiledRules {
137
105
  return rules.allowRoots.length === 0 ? rules : { ...rules, allowRoots: [] };
138
106
  }
139
107
 
140
- /**
141
- * 解析 `access` 配置:接受字符串数组(每项一条规则)或多行文本(每行一条规则),
142
- * 空行忽略;`rw <path>` 是额外可写根,`r- <path>` 是只读,`-- <pattern>` 是访问
143
- * 拒绝(读 + 写)。缺前缀或前缀后没有路径都直接报错——规则不因写法歧义而变形。
144
- * @param input - 配置里的 `access` 值。
145
- * @returns `allowWrite` / `readOnly` / `deny` 三组规则(模板尚未展开)。
146
- */
147
108
  export function parseAccess(input: string | readonly string[] | undefined): RuleSource {
148
109
  const lines = (input === undefined ? [] : typeof input === "string" ? [input] : [...input])
149
110
  .flatMap((value) => value.split(/\r?\n/u))
@@ -170,13 +131,6 @@ export function parseAccess(input: string | readonly string[] | undefined): Rule
170
131
  return { allowWrite, readOnly, deny };
171
132
  }
172
133
 
173
- /**
174
- * 把配置里的 `access` 转成规则来源:模板在这一步展开(加载期,fail-fast),
175
- * 相对路径留待按调用时的工作区根绝对化。
176
- * @param config - 含 `access` 的插件配置。
177
- * @param env - 模板展开用的进程环境。
178
- * @returns 模板已展开的规则来源。
179
- */
180
134
  export function ruleSourceOf(
181
135
  config: { access?: string | readonly string[] },
182
136
  env: NodeJS.ProcessEnv,
@@ -191,12 +145,10 @@ export function ruleSourceOf(
191
145
  };
192
146
  }
193
147
 
194
- /** 绝对化一条规则:相对路径相对工作区根。 */
195
148
  function absolutize(value: string, workspaceRoot: string): string {
196
149
  return isAbsolute(value) ? value : resolve(workspaceRoot, value);
197
150
  }
198
151
 
199
- /** 编译一组字面路径 / glob 规则:字面项按子树(含全部后代)匹配,glob 项按整串匹配。 */
200
152
  function compilePaths(
201
153
  values: readonly string[],
202
154
  workspaceRoot: string,
@@ -215,13 +167,6 @@ function compilePaths(
215
167
  return { subtrees, patterns };
216
168
  }
217
169
 
218
- /**
219
- * 按一个工作区根编译规则。
220
- * `rw` 条目必须是具体路径(glob 无法表达“可写根”);`r-` 与 `--` 条目允许 glob。
221
- * @param source - 已展开模板的规则来源。
222
- * @param workspaceRoot - 相对规则解析用的工作区根(canonical 绝对路径)。
223
- * @returns 编译后的规则。
224
- */
225
170
  export function compileRules(source: RuleSource, workspaceRoot: string): CompiledRules {
226
171
  const allowRoots: string[] = [];
227
172
  for (const value of source.allowWrite) {
@@ -241,13 +186,6 @@ export function compileRules(source: RuleSource, workspaceRoot: string): Compile
241
186
  };
242
187
  }
243
188
 
244
- /**
245
- * 一次调用可写入的根集合:官方 `writableRoots` 加上 `rw` 条目;`read-only` 不追加
246
- * (额外可写根不放松显式选定的只读边界)。
247
- * @param rules - 已编译规则。
248
- * @param policy - per-call 策略。
249
- * @returns canonical 可写根列表。
250
- */
251
189
  export function writableRootsWith(
252
190
  rules: CompiledRules,
253
191
  policy: SandboxExecutionPolicy,
@@ -256,13 +194,11 @@ export function writableRootsWith(
256
194
  return policy.mode === "workspace-write" ? [...roots, ...rules.allowRoots] : roots;
257
195
  }
258
196
 
259
- /** 字面规则的后代判定(canonical 拼写,与 Seatbelt `subpath` 同一语义)。 */
260
197
  function isUnder(path: string, root: string): boolean {
261
198
  if (path === root) return true;
262
199
  return path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
263
200
  }
264
201
 
265
- /** 命中任一子树或任一模式。 */
266
202
  function matches(
267
203
  canonicalTarget: string,
268
204
  subtrees: readonly string[],
@@ -274,32 +210,14 @@ function matches(
274
210
  return patterns.some((pattern) => pattern.regex.test(canonicalTarget));
275
211
  }
276
212
 
277
- /**
278
- * 判断目标是否被 `--` 条目命中(读与写都拒)。
279
- * @param rules - 已编译规则。
280
- * @param canonicalTarget - 目标的 canonical 路径。
281
- * @returns 命中即 true。
282
- */
283
213
  export function isDenied(rules: CompiledRules, canonicalTarget: string): boolean {
284
214
  return matches(canonicalTarget, rules.denySubtrees, rules.denyPatterns);
285
215
  }
286
216
 
287
- /**
288
- * 判断目标是否被 `r-` 条目命中(读放行、写拒绝)。
289
- * @param rules - 已编译规则。
290
- * @param canonicalTarget - 目标的 canonical 路径。
291
- * @returns 命中即 true。
292
- */
293
217
  export function isReadOnly(rules: CompiledRules, canonicalTarget: string): boolean {
294
218
  return matches(canonicalTarget, rules.readOnlySubtrees, rules.readOnlyPatterns);
295
219
  }
296
220
 
297
- /**
298
- * 判断目标是否不可写:`--` 与 `r-` 条目都拒绝写入,且优先于任何可写根。
299
- * @param rules - 已编译规则。
300
- * @param canonicalTarget - 目标的 canonical 路径。
301
- * @returns 命中即 true。
302
- */
303
221
  export function blocksWrite(rules: CompiledRules, canonicalTarget: string): boolean {
304
222
  return isDenied(rules, canonicalTarget) || isReadOnly(rules, canonicalTarget);
305
223
  }
package/src/sandbox.ts CHANGED
@@ -1,13 +1,3 @@
1
- /**
2
- * 进程沙箱 provider:继承官方 `@deepseek-ai/dsh-sandbox-local` 的实现(runner 探测与
3
- * 选择、Windows ACL 的私有 temp 流程、拒绝方言与 runner 失败规则全部保留),
4
- * 只在 `confine` 产出的 argv 上追加本包的 `access` 条目(`rw` / `r-` / `--`)。
5
- *
6
- * 服务键沿用上游基类的 `ctx.sandbox`;装配时官方 `sandbox` 行必须被禁用,
7
- * 否则同名服务会 fail loud。
8
- * @module @morlay/dsh-sandbox-local/sandbox
9
- */
10
-
11
1
  import type { Context } from "@deepseek-ai/cordis";
12
2
  import type { ConfinedArgv, SandboxPolicy } from "@deepseek-ai/dsh-sandbox";
13
3
  import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
@@ -22,14 +12,9 @@ import {
22
12
  type RuleSource,
23
13
  } from "./rules.ts";
24
14
 
25
- /**
26
- * 官方本机沙箱 provider 的可配置版本。
27
- * `read-only` 模式不追加 `rw` 条目(只读边界不因额外可写根放松);`--` 条目在两种
28
- * confined 模式下都生效。
29
- */
30
15
  export class ConfigurableSandboxProvider extends LocalSandboxProvider {
31
16
  private readonly source: RuleSource;
32
- /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
17
+
33
18
  private readonly compiled = new Map<string, CompiledRules>();
34
19
 
35
20
  constructor(ctx: Context, config: Config) {
@@ -37,21 +22,18 @@ export class ConfigurableSandboxProvider extends LocalSandboxProvider {
37
22
  this.source = ruleSourceOf(config, process.env);
38
23
  }
39
24
 
40
- /**
41
- * 官方拼装 + 规则追加。
42
- * @param argv - 调用方即将 spawn 的 argv。
43
- * @param policy - 本次调用的文件效果策略。
44
- * @returns 追加规则后的 confined argv(空规则时与官方结果一致)。
45
- */
46
- override confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
47
- const confined = super.confine(argv, policy);
25
+ override async confine(
26
+ argv: readonly string[],
27
+ policy: SandboxPolicy,
28
+ signal?: AbortSignal,
29
+ ): Promise<ConfinedArgv> {
30
+ const confined = await super.confine(argv, policy, signal);
48
31
  const rules = this.rulesFor(policy.workspaceRoot);
49
32
  const effective = policy.mode === "workspace-write" ? rules : withoutAllowRoots(rules);
50
33
  if (isEmptyRules(effective)) return confined;
51
34
  return { ...confined, argv: extendConfinedArgv(confined.argv, effective) };
52
35
  }
53
36
 
54
- /** 取(并按需编译缓存)某个工作区根下的规则。 */
55
37
  private rulesFor(workspaceRoot: string): CompiledRules {
56
38
  const cached = this.compiled.get(workspaceRoot);
57
39
  if (cached !== undefined) return cached;