@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/src/rules.ts ADDED
@@ -0,0 +1,305 @@
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
+ import { isAbsolute, resolve, sep } from "node:path";
14
+ import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
15
+ import type { SandboxExecutionPolicy } from "@deepseek-ai/dsh-sandbox";
16
+
17
+ /** `{{ env.NAME }}` 模板;名字限定为环境变量的字符集。 */
18
+ const ENV_TEMPLATE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
19
+
20
+ /** `access` 的条目形式:`rw <path>` / `r- <path>` / `-- <pattern>`;前缀后必须有空白。 */
21
+ const ACCESS_LINE = /^(rw|r-|--)(?:\s+(.*))?$/u;
22
+
23
+ /** glob 元字符:规则里出现即按模式匹配,否则按字面路径(含其全部后代)匹配。 */
24
+ const GLOB_META = /[*?[]/;
25
+
26
+ /** glob 翻译时需要转义的正则元字符(字符类内部除外)。 */
27
+ const REGEX_META = /[\\^$+.(){}|]/;
28
+
29
+ /** 展开规则里的 `{{ env.NAME }}`;引用未定义或为空的环境变量直接抛错。 */
30
+ export function expandEnvTemplates(value: string, env: NodeJS.ProcessEnv): string {
31
+ return value.replace(ENV_TEMPLATE, (_match, name: string) => {
32
+ const resolved = env[name];
33
+ if (resolved === undefined || resolved.length === 0) {
34
+ throw new Error(
35
+ `sandbox rules: "${value}" references the unset environment variable "${name}"`,
36
+ );
37
+ }
38
+ return resolved;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * 把含 glob 的规则翻译成正则源码。
44
+ * `**` 跨目录层级(`**` 与“`**` 后接斜杠”都能匹配零层),`*` 与 `?` 不跨 `/`,
45
+ * 字符类透传(`[!…]` 按 glob 习惯翻成 `[^…]`),其余正则元字符转义。
46
+ * @param pattern - 已绝对化的 glob 规则。
47
+ * @returns 可同时被 JS `RegExp` 与 SBPL regex 接受的正则源码。
48
+ */
49
+ export function globToRegexSource(pattern: string): string {
50
+ let source = "";
51
+ for (let index = 0; index < pattern.length; index += 1) {
52
+ const char = pattern[index] as string;
53
+ if (char === "*") {
54
+ if (pattern[index + 1] === "*") {
55
+ index += 1;
56
+ if (pattern[index + 1] === "/") {
57
+ index += 1;
58
+ source += "(.*/)?";
59
+ } else {
60
+ source += ".*";
61
+ }
62
+ } else {
63
+ source += "[^/]*";
64
+ }
65
+ continue;
66
+ }
67
+ if (char === "?") {
68
+ source += "[^/]";
69
+ continue;
70
+ }
71
+ if (char === "[") {
72
+ const close = pattern.indexOf("]", index + 1);
73
+ if (close === -1) {
74
+ source += "\\[";
75
+ continue;
76
+ }
77
+ const body = pattern.slice(index + 1, close);
78
+ if (body.length === 0) {
79
+ source += "\\[\\]";
80
+ index = close;
81
+ continue;
82
+ }
83
+ source += body.startsWith("!") ? `[^${body.slice(1)}]` : `[${body}]`;
84
+ index = close;
85
+ continue;
86
+ }
87
+ source += REGEX_META.test(char) ? `\\${char}` : char;
88
+ }
89
+ return source;
90
+ }
91
+
92
+ /** 规则来源:模板已在加载期展开,路径仍是相对/绝对的原始拼写。 */
93
+ export interface RuleSource {
94
+ /** `rw` 条目:额外可写根。 */
95
+ readonly allowWrite: readonly string[];
96
+ /** `r-` 条目:只读(读放行、写拒绝)。 */
97
+ readonly readOnly: readonly string[];
98
+ /** `--` 条目:拒绝访问(读 + 写)。 */
99
+ readonly deny: readonly string[];
100
+ }
101
+
102
+ /** 一条已编译的模式:源码同时供 JS `RegExp` 与 SBPL regex 使用。 */
103
+ export interface CompiledPattern {
104
+ /** 正则源码。 */
105
+ readonly source: string;
106
+ /** 进程内检查用的已编译正则。 */
107
+ readonly regex: RegExp;
108
+ }
109
+
110
+ /** 编译后的规则:按一个工作区根绝对化后的可写根、只读项与拒绝项。 */
111
+ export interface CompiledRules {
112
+ /** 额外可写根(canonical 绝对路径)。 */
113
+ readonly allowRoots: readonly string[];
114
+ /** 只读的字面路径(canonical):命中自身及其全部后代。 */
115
+ readonly readOnlySubtrees: readonly string[];
116
+ /** 只读的模式。 */
117
+ readonly readOnlyPatterns: readonly CompiledPattern[];
118
+ /** 拒绝访问的字面路径(canonical):命中自身及其全部后代。 */
119
+ readonly denySubtrees: readonly string[];
120
+ /** 拒绝访问的模式。 */
121
+ readonly denyPatterns: readonly CompiledPattern[];
122
+ }
123
+
124
+ /** 规则是否为空——空规则下 provider 不改写任何 runner 参数。 */
125
+ export function isEmptyRules(rules: CompiledRules): boolean {
126
+ return (
127
+ rules.allowRoots.length === 0 &&
128
+ rules.readOnlySubtrees.length === 0 &&
129
+ rules.readOnlyPatterns.length === 0 &&
130
+ rules.denySubtrees.length === 0 &&
131
+ rules.denyPatterns.length === 0
132
+ );
133
+ }
134
+
135
+ /** 只读模式不因 `rw` 条目放松:投影出只保留 `r-` / `--` 条目的规则集。 */
136
+ export function withoutAllowRoots(rules: CompiledRules): CompiledRules {
137
+ return rules.allowRoots.length === 0 ? rules : { ...rules, allowRoots: [] };
138
+ }
139
+
140
+ /**
141
+ * 解析 `access` 配置:接受字符串数组(每项一条规则)或多行文本(每行一条规则),
142
+ * 空行忽略;`rw <path>` 是额外可写根,`r- <path>` 是只读,`-- <pattern>` 是访问
143
+ * 拒绝(读 + 写)。缺前缀或前缀后没有路径都直接报错——规则不因写法歧义而变形。
144
+ * @param input - 配置里的 `access` 值。
145
+ * @returns `allowWrite` / `readOnly` / `deny` 三组规则(模板尚未展开)。
146
+ */
147
+ export function parseAccess(input: string | readonly string[] | undefined): RuleSource {
148
+ const lines = (input === undefined ? [] : typeof input === "string" ? [input] : [...input])
149
+ .flatMap((value) => value.split(/\r?\n/u))
150
+ .map((line) => line.trim())
151
+ .filter((line) => line.length > 0);
152
+ const allowWrite: string[] = [];
153
+ const readOnly: string[] = [];
154
+ const deny: string[] = [];
155
+ for (const line of lines) {
156
+ const match = ACCESS_LINE.exec(line);
157
+ if (match === null) {
158
+ throw new Error(
159
+ `sandbox rules: access entry ${JSON.stringify(line)} must start with "rw " (write), "r- " (read-only) or "-- " (deny)`,
160
+ );
161
+ }
162
+ const rule = (match[2] ?? "").trim();
163
+ if (rule.length === 0) {
164
+ throw new Error(`sandbox rules: access entry ${JSON.stringify(line)} carries no path`);
165
+ }
166
+ if (match[1] === "rw") allowWrite.push(rule);
167
+ else if (match[1] === "r-") readOnly.push(rule);
168
+ else deny.push(rule);
169
+ }
170
+ return { allowWrite, readOnly, deny };
171
+ }
172
+
173
+ /**
174
+ * 把配置里的 `access` 转成规则来源:模板在这一步展开(加载期,fail-fast),
175
+ * 相对路径留待按调用时的工作区根绝对化。
176
+ * @param config - 含 `access` 的插件配置。
177
+ * @param env - 模板展开用的进程环境。
178
+ * @returns 模板已展开的规则来源。
179
+ */
180
+ export function ruleSourceOf(
181
+ config: { access?: string | readonly string[] },
182
+ env: NodeJS.ProcessEnv,
183
+ ): RuleSource {
184
+ const parsed = parseAccess(config.access);
185
+ const expand = (values: readonly string[]): string[] =>
186
+ values.map((value) => expandEnvTemplates(value, env));
187
+ return {
188
+ allowWrite: expand(parsed.allowWrite),
189
+ readOnly: expand(parsed.readOnly),
190
+ deny: expand(parsed.deny),
191
+ };
192
+ }
193
+
194
+ /** 绝对化一条规则:相对路径相对工作区根。 */
195
+ function absolutize(value: string, workspaceRoot: string): string {
196
+ return isAbsolute(value) ? value : resolve(workspaceRoot, value);
197
+ }
198
+
199
+ /** 编译一组字面路径 / glob 规则:字面项按子树(含全部后代)匹配,glob 项按整串匹配。 */
200
+ function compilePaths(
201
+ values: readonly string[],
202
+ workspaceRoot: string,
203
+ ): { subtrees: string[]; patterns: CompiledPattern[] } {
204
+ const subtrees: string[] = [];
205
+ const patterns: CompiledPattern[] = [];
206
+ for (const value of values) {
207
+ const absolute = absolutize(value, workspaceRoot);
208
+ if (GLOB_META.test(value)) {
209
+ const pattern = `^${globToRegexSource(absolute)}$`;
210
+ patterns.push({ source: pattern, regex: new RegExp(pattern) });
211
+ } else {
212
+ subtrees.push(canonicalPath(absolute));
213
+ }
214
+ }
215
+ return { subtrees, patterns };
216
+ }
217
+
218
+ /**
219
+ * 按一个工作区根编译规则。
220
+ * `rw` 条目必须是具体路径(glob 无法表达“可写根”);`r-` 与 `--` 条目允许 glob。
221
+ * @param source - 已展开模板的规则来源。
222
+ * @param workspaceRoot - 相对规则解析用的工作区根(canonical 绝对路径)。
223
+ * @returns 编译后的规则。
224
+ */
225
+ export function compileRules(source: RuleSource, workspaceRoot: string): CompiledRules {
226
+ const allowRoots: string[] = [];
227
+ for (const value of source.allowWrite) {
228
+ if (GLOB_META.test(value)) {
229
+ throw new Error(`sandbox rules: rw entry "${value}" must name a concrete path, not a glob`);
230
+ }
231
+ allowRoots.push(canonicalPath(absolutize(value, workspaceRoot)));
232
+ }
233
+ const readOnly = compilePaths(source.readOnly, workspaceRoot);
234
+ const deny = compilePaths(source.deny, workspaceRoot);
235
+ return {
236
+ allowRoots,
237
+ readOnlySubtrees: readOnly.subtrees,
238
+ readOnlyPatterns: readOnly.patterns,
239
+ denySubtrees: deny.subtrees,
240
+ denyPatterns: deny.patterns,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * 一次调用可写入的根集合:官方 `writableRoots` 加上 `rw` 条目;`read-only` 不追加
246
+ * (额外可写根不放松显式选定的只读边界)。
247
+ * @param rules - 已编译规则。
248
+ * @param policy - per-call 策略。
249
+ * @returns canonical 可写根列表。
250
+ */
251
+ export function writableRootsWith(
252
+ rules: CompiledRules,
253
+ policy: SandboxExecutionPolicy,
254
+ ): readonly string[] {
255
+ const roots = writableRoots(policy);
256
+ return policy.mode === "workspace-write" ? [...roots, ...rules.allowRoots] : roots;
257
+ }
258
+
259
+ /** 字面规则的后代判定(canonical 拼写,与 Seatbelt `subpath` 同一语义)。 */
260
+ function isUnder(path: string, root: string): boolean {
261
+ if (path === root) return true;
262
+ return path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
263
+ }
264
+
265
+ /** 命中任一子树或任一模式。 */
266
+ function matches(
267
+ canonicalTarget: string,
268
+ subtrees: readonly string[],
269
+ patterns: readonly CompiledPattern[],
270
+ ): boolean {
271
+ for (const subtree of subtrees) {
272
+ if (isUnder(canonicalTarget, subtree)) return true;
273
+ }
274
+ return patterns.some((pattern) => pattern.regex.test(canonicalTarget));
275
+ }
276
+
277
+ /**
278
+ * 判断目标是否被 `--` 条目命中(读与写都拒)。
279
+ * @param rules - 已编译规则。
280
+ * @param canonicalTarget - 目标的 canonical 路径。
281
+ * @returns 命中即 true。
282
+ */
283
+ export function isDenied(rules: CompiledRules, canonicalTarget: string): boolean {
284
+ return matches(canonicalTarget, rules.denySubtrees, rules.denyPatterns);
285
+ }
286
+
287
+ /**
288
+ * 判断目标是否被 `r-` 条目命中(读放行、写拒绝)。
289
+ * @param rules - 已编译规则。
290
+ * @param canonicalTarget - 目标的 canonical 路径。
291
+ * @returns 命中即 true。
292
+ */
293
+ export function isReadOnly(rules: CompiledRules, canonicalTarget: string): boolean {
294
+ return matches(canonicalTarget, rules.readOnlySubtrees, rules.readOnlyPatterns);
295
+ }
296
+
297
+ /**
298
+ * 判断目标是否不可写:`--` 与 `r-` 条目都拒绝写入,且优先于任何可写根。
299
+ * @param rules - 已编译规则。
300
+ * @param canonicalTarget - 目标的 canonical 路径。
301
+ * @returns 命中即 true。
302
+ */
303
+ export function blocksWrite(rules: CompiledRules, canonicalTarget: string): boolean {
304
+ return isDenied(rules, canonicalTarget) || isReadOnly(rules, canonicalTarget);
305
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,64 @@
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
+ import type { Context } from "@deepseek-ai/cordis";
12
+ import type { ConfinedArgv, SandboxPolicy } from "@deepseek-ai/dsh-sandbox";
13
+ import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
14
+ import type { Config } from "./config.ts";
15
+ import { extendConfinedArgv } from "./dialects.ts";
16
+ import {
17
+ compileRules,
18
+ isEmptyRules,
19
+ ruleSourceOf,
20
+ withoutAllowRoots,
21
+ type CompiledRules,
22
+ type RuleSource,
23
+ } from "./rules.ts";
24
+
25
+ /**
26
+ * 官方本机沙箱 provider 的可配置版本。
27
+ * `read-only` 模式不追加 `rw` 条目(只读边界不因额外可写根放松);`--` 条目在两种
28
+ * confined 模式下都生效。
29
+ */
30
+ export class ConfigurableSandboxProvider extends LocalSandboxProvider {
31
+ private readonly source: RuleSource;
32
+ /** 规则按工作区根编译一次(相对规则相对该调用的工作区)。 */
33
+ private readonly compiled = new Map<string, CompiledRules>();
34
+
35
+ constructor(ctx: Context, config: Config) {
36
+ super(ctx, config);
37
+ this.source = ruleSourceOf(config, process.env);
38
+ }
39
+
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);
48
+ const rules = this.rulesFor(policy.workspaceRoot);
49
+ const effective = policy.mode === "workspace-write" ? rules : withoutAllowRoots(rules);
50
+ if (isEmptyRules(effective)) return confined;
51
+ return { ...confined, argv: extendConfinedArgv(confined.argv, effective) };
52
+ }
53
+
54
+ /** 取(并按需编译缓存)某个工作区根下的规则。 */
55
+ private rulesFor(workspaceRoot: string): CompiledRules {
56
+ const cached = this.compiled.get(workspaceRoot);
57
+ if (cached !== undefined) return cached;
58
+ const compiled = compileRules(this.source, workspaceRoot);
59
+ this.compiled.set(workspaceRoot, compiled);
60
+ return compiled;
61
+ }
62
+ }
63
+
64
+ export default ConfigurableSandboxProvider;