@moonquake2004/dsh-security 0.1.6 → 0.2.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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * SP13: Tools Mode × Sandbox Mismatch — Code Mode 绕过文件效应沙箱
3
+ *
4
+ * 出处:#3245(Critical,在 rc.7 / rc.8 上被两人独立复现)——
5
+ * 当解析后的 tools mode 为 `ptc` / `both`(即 Code Mode / PTC,
6
+ * 模型只拿到 `run_code` + 生成的 SDK),而沙箱仍处于限制性模式
7
+ * (`read-only` / `workspace-write`)时,`run_code` 路径把模型写的
8
+ * 程序送进 worker 线程执行,**不经过 `ctx.sandbox.confine()`**:
9
+ * 程序可 `import('node:fs')` / `child_process`,从而获得完整宿主
10
+ * 文件与进程权限。操作者以为生效的沙箱对代码执行不适用。
11
+ *
12
+ * 本检查纯粹由**已解析配置**判定,近零误报:
13
+ * - tools mode 来源(后者覆盖前者):
14
+ * 1. host bundle 各层(profile `dsh.profile.bundles` 顺序)中的
15
+ * `- id: tools` 行 `config.mode`
16
+ * 2. profile 自身 `cordis.patch.yml`
17
+ * 3. `DSH_TOOLS_MODE` 环境变量(当某层写成
18
+ * `mode: !!js process.env.DSH_TOOLS_MODE` 时生效)
19
+ * 4. 生效的 agent preset(`settings.yaml` 的 `agent-presets.default`)
20
+ * 的 `tool-presentation.config.mode` —— 这是 per-agent 开启 PTC
21
+ * 的第二条路径(presets/ptc/agent.cordis.yml)
22
+ * - sandbox mode 来源(后者覆盖前者):
23
+ * 1. 各层的 `- id: sandbox-policy` 行 `config.mode`
24
+ * 2. `DSH_PERMISSION_MODE` 环境变量(同上)
25
+ * 3. `settings.yaml` 的 `permission.defaultPreset` → 映射到
26
+ * `permission.config.presets.<name>.sandbox`(新会话首帧即生效)
27
+ *
28
+ * 已核实的真实配置键(2026-09,dsh 0.1.5-alpha.1 安装树):
29
+ * - `dsh-tools/lib/index.js:2570-2574`:`Config.mode` ∈
30
+ * {native, ptc, both},`.default("native")`
31
+ * - `dsh-base/cordis.patch.yml:461-463`:`- id: tools`,不带 config
32
+ * - `dsh-web-app/cordis.patch.yml:34-38`:`- id: tools / config: mode:
33
+ * !!js process.env.DSH_TOOLS_MODE`(headless 同款在 :16)
34
+ * - `dsh-base/cordis.patch.yml:208-211`:`- id: sandbox-policy /
35
+ * config.mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'`
36
+ * - `dsh-base/cordis.patch.yml:229-242`:`- id: permission / config.presets`
37
+ * - `dsh-permission-presets/lib/index.js:24`:settings 命名空间 `permission`,
38
+ * schema 仅 `{ defaultPreset }`;`:293-305` 新会话按 defaultPreset 调
39
+ * `setSandboxMode`
40
+ * - `dsh-agent-tool-presentation/lib/index.js:31-47`:per-agent `mode`
41
+ * - `dsh-agent-presets/presets/ptc/agent.cordis.yml:270-272`:
42
+ * `- id: tool-presentation / config.mode: ptc`
43
+ *
44
+ * Severity: CRITICAL(默认策略下的静默逃逸;#3245 评分 9.8-10.0)
45
+ * Phase: POST_INSTALL
46
+ */
47
+
48
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
49
+ import { join, dirname, resolve as resolvePath } from 'node:path';
50
+ import { homedir } from 'node:os';
51
+ import { Severity } from '../protocol/severity.mjs';
52
+ import { CheckPhase } from '../protocol/phase.mjs';
53
+ import { pass, fail, skip } from '../protocol/check.mjs';
54
+
55
+ const ID = 'SP13';
56
+
57
+ /** `dsh-tools` 接受的 mode(lib/index.js:2570-2574) */
58
+ const TOOLS_MODES = new Set(['native', 'ptc', 'both']);
59
+ /** 开启 Code Mode(run_code 传输)的值 */
60
+ const CODE_MODES = new Set(['ptc', 'both']);
61
+ /** `dsh-sandbox-policy` 接受的 mode */
62
+ const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
63
+ /** 限制性沙箱 —— Code Mode 在其下即为逃逸 */
64
+ const RESTRICTIVE_SANDBOX = new Set(['read-only', 'workspace-write']);
65
+
66
+ /**
67
+ * 内建 preset 表(dsh-base/cordis.patch.yml:229-242 已核实)。
68
+ * 作为解析起点;任何层里出现的 presets 覆盖同名项。
69
+ */
70
+ const BASE_PRESETS = Object.freeze({
71
+ 'read-only': { sandbox: 'read-only', approval: 'ask' },
72
+ 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
73
+ 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
74
+ });
75
+
76
+ /* ────────────────────────── 极简 YAML 读取原语 ──────────────────────────
77
+ * 项目零依赖(package.json engines 之外无 deps),且只需要读 patch 行里
78
+ * 的少量标量,故用缩进感知的行解析而非引入 YAML 库。所有函数都只读。 */
79
+
80
+ function readText(p) {
81
+ try { return readFileSync(p, 'utf8'); } catch { return null; }
82
+ }
83
+
84
+ function readJson(p) {
85
+ const t = readText(p);
86
+ if (t === null) return null;
87
+ try { return JSON.parse(t); } catch { return null; }
88
+ }
89
+
90
+ function unquote(v) {
91
+ const s = String(v).trim();
92
+ if (s.length >= 2 && ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"')))) {
93
+ return s.slice(1, -1);
94
+ }
95
+ return s;
96
+ }
97
+
98
+ function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
99
+
100
+ /**
101
+ * 取 patch 内容中 `- id: <rowId>` 这一项的文本块(到同/更浅缩进的下一个
102
+ * `- ` 项为止)。找不到返回 null。
103
+ */
104
+ function rowBlock(content, rowId) {
105
+ const re = new RegExp(`^([ \\t]*)-[ \\t]*id:[ \\t]*['"]?${escapeRe(rowId)}['"]?[ \\t]*$`, 'm');
106
+ const m = re.exec(content);
107
+ if (!m) return null;
108
+ const indent = m[1].length;
109
+ const after = content.slice(m.index + m[0].length);
110
+ let end = after.length;
111
+ let offset = 0;
112
+ for (const line of after.split('\n')) {
113
+ const lm = /^([ \t]*)-[ \t]+\S/.exec(line);
114
+ if (lm && lm[1].length <= indent) { end = offset; break; }
115
+ offset += line.length + 1;
116
+ }
117
+ return after.slice(0, end);
118
+ }
119
+
120
+ /**
121
+ * 取块内的 `config:` 段(到缩进 <= config 键的那一行为止)。
122
+ * 返回 { text, indent } 或 null。
123
+ */
124
+ function configSection(block) {
125
+ const m = /^([ \t]*)config:[ \t]*$/m.exec(block);
126
+ if (!m) return null;
127
+ const indent = m[1].length;
128
+ const after = block.slice(m.index + m[0].length);
129
+ const out = [];
130
+ for (const line of after.split('\n')) {
131
+ if (line.trim() === '') { out.push(line); continue; }
132
+ const lm = /^([ \t]*)\S/.exec(line);
133
+ if (lm && lm[1].length <= indent) break;
134
+ out.push(line);
135
+ }
136
+ return { text: out.join('\n'), indent };
137
+ }
138
+
139
+ /** 取段内某个子映射(如 config 下的 `presets:`)的文本块。 */
140
+ function subSection(section, key) {
141
+ const lines = section.text.split('\n');
142
+ let idx = -1;
143
+ let indent = -1;
144
+ for (let i = 0; i < lines.length; i++) {
145
+ const m = /^([ \t]*)([A-Za-z0-9_.-]+):[ \t]*$/.exec(lines[i]);
146
+ if (m && m[2] === key && m[1].length > section.indent) { idx = i; indent = m[1].length; break; }
147
+ }
148
+ if (idx < 0) return null;
149
+ const out = [];
150
+ for (let i = idx + 1; i < lines.length; i++) {
151
+ const line = lines[i];
152
+ if (line.trim() === '') { out.push(line); continue; }
153
+ const lm = /^([ \t]*)\S/.exec(line);
154
+ if (lm && lm[1].length <= indent) break;
155
+ out.push(line);
156
+ }
157
+ return { text: out.join('\n'), indent };
158
+ }
159
+
160
+ /**
161
+ * 取段内直接子键的标量原文(缩进必须比段本身深)。找不到返回 undefined。
162
+ */
163
+ function scalarAt(section, key) {
164
+ for (const line of section.text.split('\n')) {
165
+ const m = /^([ \t]*)([A-Za-z0-9_.-]+):[ \t]*(.*)$/.exec(line);
166
+ if (!m) continue;
167
+ if (m[2] !== key) continue;
168
+ if (m[1].length <= section.indent) continue;
169
+ return m[3].trim();
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * 求值一个标量原文。返回 { known, value, envUnset }:
176
+ * - 纯字面量 → value = 去引号后的字符串
177
+ * - `!!js process.env.NAME`(可带 `?? 'default'` / `|| 'default'`)→
178
+ * 环境变量有值则用之,否则用 fallback;无 fallback 时 value = undefined
179
+ * (由调用方套 schema 默认值)并记录 envUnset
180
+ * - 其它 `!!js` 表达式 / 块标量 → known=false(不猜)
181
+ */
182
+ function evalScalar(raw, env) {
183
+ if (raw === undefined) return { known: true, value: undefined };
184
+ const v = String(raw).trim();
185
+ if (v === '' || v === '>' || v === '>-' || v === '>' + '+' || v === '|' || v === '|-' || v === '|+') {
186
+ return { known: false, reason: '块标量,静态不可解析' };
187
+ }
188
+ if (v.startsWith('!!js')) {
189
+ const expr = v.replace(/^!!js\s+/, '').trim();
190
+ const m = /^process\.env\.([A-Za-z_][A-Za-z0-9_]*)(?:\s*(?:\?\?|\|\|)\s*(['"])(.*?)\2)?$/.exec(expr);
191
+ if (!m) return { known: false, reason: `非静态可解析的 !!js 表达式: ${expr}` };
192
+ const envVal = env[m[1]];
193
+ if (typeof envVal === 'string' && envVal.length > 0) return { known: true, value: envVal, envName: m[1] };
194
+ if (m[2] !== undefined) return { known: true, value: m[3], envName: m[1], envFellBack: true };
195
+ return { known: true, value: undefined, envName: m[1], envUnset: true };
196
+ }
197
+ if (v.startsWith('!!')) return { known: false, reason: `不支持的 YAML tag: ${v.slice(0, 16)}` };
198
+ return { known: true, value: unquote(v) };
199
+ }
200
+
201
+ /* ────────────────────────── 配置定位 ────────────────────────── */
202
+
203
+ /** 从 profileDir 推断 DSH_HOME(`<home>/profiles/<name>` → `<home>`)。 */
204
+ function inferDshHome(profileDir, env) {
205
+ if (env.DSH_HOME) return env.DSH_HOME;
206
+ const resolved = resolvePath(profileDir);
207
+ const m = /^(.*)[\\/]profiles[\\/][^\\/]+$/.exec(resolved);
208
+ if (m && m[1]) return m[1];
209
+ return join(homedir(), '.dsh');
210
+ }
211
+
212
+ /** 从 PATH 里的 `dsh` 反查 CLI 安装根(`<root>/node_modules/@deepseek-ai/dsh`)。 */
213
+ function resolveCliRoots(env) {
214
+ const roots = [];
215
+ const push = (p) => { if (p && !roots.includes(p)) roots.push(p); };
216
+ if (env.DSH_CLI_ROOT) push(env.DSH_CLI_ROOT);
217
+ for (const dir of String(env.PATH || '').split(':')) {
218
+ if (!dir) continue;
219
+ const bin = join(dir, 'dsh');
220
+ try {
221
+ if (!existsSync(bin)) continue;
222
+ const real = realpathSync(bin); // .../@deepseek-ai/dsh/lib/bin.js
223
+ push(dirname(dirname(real))); // .../@deepseek-ai/dsh
224
+ } catch { /* 非实际文件 / 权限问题 → 跳过 */ }
225
+ }
226
+ return roots;
227
+ }
228
+
229
+ /** 解析一个 bundle 包目录:profile 自身 → 逐级父目录的 node_modules → CLI 根。 */
230
+ function resolveBundleDir(name, profileDir, cliRoots) {
231
+ const cands = [join(profileDir, 'node_modules', name)];
232
+ let dir = resolvePath(profileDir);
233
+ for (let i = 0; i < 8; i++) {
234
+ const parent = dirname(dir);
235
+ if (parent === dir) break;
236
+ cands.push(join(parent, 'node_modules', name));
237
+ dir = parent;
238
+ }
239
+ for (const root of cliRoots) {
240
+ cands.push(join(root, 'node_modules', name));
241
+ cands.push(join(root, name));
242
+ }
243
+ for (const c of cands) {
244
+ try { if (existsSync(c)) return c; } catch { /* 跳过 */ }
245
+ }
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * 按 profile 声明的 bundle 顺序收集所有 patch 层,最后追加 profile 自身
251
+ * 的 cordis.patch.yml(用户 patch 最后应用)。
252
+ */
253
+ function collectLayers(profileDir, cliRoots) {
254
+ const layers = [];
255
+ const pkg = readJson(join(profileDir, 'package.json'));
256
+ const bundles = pkg && pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
257
+ ? pkg.dsh.profile.bundles
258
+ : null;
259
+ if (bundles) {
260
+ for (const name of bundles) {
261
+ if (typeof name !== 'string') continue;
262
+ const dir = resolveBundleDir(name, profileDir, cliRoots);
263
+ if (!dir) continue;
264
+ const patchPath = join(dir, 'cordis.patch.yml');
265
+ const content = readText(patchPath);
266
+ if (content !== null) layers.push({ name, path: patchPath, content });
267
+ }
268
+ }
269
+ const profilePatch = join(profileDir, 'cordis.patch.yml');
270
+ const profileContent = readText(profilePatch);
271
+ if (profileContent !== null) layers.push({ name: '(profile cordis.patch.yml)', path: profilePatch, content: profileContent });
272
+ return { layers, declaredBundles: bundles };
273
+ }
274
+
275
+ /**
276
+ * 解析某个行 id 的生效标量(如 tools.config.mode)。
277
+ * 语义对齐 patch:后层覆盖前层;某层给了 `config:` 就整体替换,
278
+ * 其中缺少该键即回到 schema 默认值。
279
+ */
280
+ function resolveRow(layers, rowId, env, schemaDefault) {
281
+ let found = false;
282
+ let configRaw = undefined;
283
+ let configLayer = null;
284
+ let configSeen = false;
285
+ let disabled = false;
286
+ let disabledLayer = null;
287
+
288
+ for (const layer of layers) {
289
+ const block = rowBlock(layer.content, rowId);
290
+ if (block === null) continue;
291
+ found = true;
292
+ const dm = /^[ \t]+disabled:[ \t]*(\S+)[ \t]*$/m.exec(block);
293
+ if (dm) {
294
+ if (dm[1] === 'true') { disabled = true; disabledLayer = layer; }
295
+ else if (dm[1] === 'false') { disabled = false; disabledLayer = null; }
296
+ // !!js 条件的 disabled → 无法静态判定,保持原状(不据此判安全)
297
+ }
298
+ const cfg = configSection(block);
299
+ if (cfg) {
300
+ configSeen = true;
301
+ configRaw = scalarAt(cfg, 'mode');
302
+ configLayer = layer;
303
+ }
304
+ }
305
+
306
+ if (!found) return { status: 'missing' };
307
+ if (disabled) return { status: 'disabled', layer: disabledLayer };
308
+
309
+ const ev = evalScalar(configRaw, env);
310
+ if (!ev.known) return { status: 'unknown', raw: configRaw, layer: configLayer, reason: ev.reason };
311
+
312
+ let value = ev.value;
313
+ let source = configLayer;
314
+ let fromSchemaDefault = false;
315
+ if (value === undefined) {
316
+ value = schemaDefault;
317
+ fromSchemaDefault = true;
318
+ }
319
+ return {
320
+ status: 'value',
321
+ value,
322
+ raw: configRaw,
323
+ layer: source,
324
+ configSeen,
325
+ fromSchemaDefault,
326
+ envName: ev.envName,
327
+ envFellBack: ev.envFellBack,
328
+ envUnset: ev.envUnset,
329
+ };
330
+ }
331
+
332
+ /** 收集权限 preset 表(内建表起步,各层 permission.config.presets 覆盖)。 */
333
+ function resolvePresets(layers) {
334
+ const presets = {};
335
+ for (const [k, v] of Object.entries(BASE_PRESETS)) presets[k] = { ...v };
336
+ for (const layer of layers) {
337
+ const block = rowBlock(layer.content, 'permission');
338
+ if (block === null) continue;
339
+ const cfg = configSection(block);
340
+ if (!cfg) continue;
341
+ const ps = subSection(cfg, 'presets');
342
+ if (!ps) continue;
343
+ const lines = ps.text.split('\n');
344
+ let current = null;
345
+ let entryIndent = -1;
346
+ for (const line of lines) {
347
+ const m = /^([ \t]*)([A-Za-z0-9_.-]+):[ \t]*(.*)$/.exec(line);
348
+ if (!m) continue;
349
+ const ind = m[1].length;
350
+ if (ind <= ps.indent) continue;
351
+ if (entryIndent === -1) entryIndent = ind;
352
+ if (ind === entryIndent) {
353
+ current = unquote(m[2]);
354
+ if (!presets[current]) presets[current] = {};
355
+ continue;
356
+ }
357
+ if (current && ind > entryIndent && m[2] === 'sandbox') presets[current].sandbox = unquote(m[3]);
358
+ }
359
+ }
360
+ return presets;
361
+ }
362
+
363
+ /** 读 `<dshHome>/settings.yaml` 的命名空间段(只取两层,够用)。 */
364
+ function readSettings(dshHome) {
365
+ for (const name of ['settings.yaml', 'settings.yml', 'settings.json']) {
366
+ const p = join(dshHome, name);
367
+ const text = readText(p);
368
+ if (text === null) continue;
369
+ if (name.endsWith('.json')) {
370
+ const j = (() => { try { return JSON.parse(text); } catch { return null; } })();
371
+ if (j && typeof j === 'object') return { path: p, sections: j };
372
+ continue;
373
+ }
374
+ const sections = {};
375
+ let current = null;
376
+ for (const line of text.split('\n')) {
377
+ if (line.trim() === '' || /^\s*#/.test(line)) continue;
378
+ const top = /^([A-Za-z0-9_.-]+):[ \t]*(.*)$/.exec(line);
379
+ if (top) {
380
+ current = top[1];
381
+ if (!sections[current] || typeof sections[current] !== 'object') sections[current] = {};
382
+ if (top[2].trim() !== '') sections[current].__value = unquote(top[2]);
383
+ continue;
384
+ }
385
+ const sub = /^[ \t]+([A-Za-z0-9_.-]+):[ \t]*(.*)$/.exec(line);
386
+ if (sub && current && sections[current] && typeof sections[current] === 'object') {
387
+ sections[current][sub[1]] = unquote(sub[2]);
388
+ }
389
+ }
390
+ return { path: p, sections };
391
+ }
392
+ return { path: null, sections: {} };
393
+ }
394
+
395
+ /**
396
+ * 解析生效 agent preset 的 per-agent tools presentation。
397
+ * 返回 { known, value, source, reason }。
398
+ */
399
+ function resolveAgentPresetMode(settings, layers, profileDir, dshHome, cliRoots, env) {
400
+ let presetId = settings.sections['agent-presets'] && settings.sections['agent-presets'].default;
401
+ let origin = presetId ? `${settings.path}: agent-presets.default=${presetId}` : null;
402
+
403
+ if (!presetId) {
404
+ for (const layer of layers) {
405
+ const block = rowBlock(layer.content, 'agent-presets');
406
+ if (block === null) continue;
407
+ const cfg = configSection(block);
408
+ if (!cfg) continue;
409
+ const d = scalarAt(cfg, 'default');
410
+ if (d !== undefined) {
411
+ presetId = unquote(d);
412
+ origin = `${layer.path}: agent-presets.config.default=${presetId}`;
413
+ }
414
+ }
415
+ }
416
+ if (!presetId) {
417
+ return { known: false, reason: 'agent-presets 默认 preset 未配置(settings.yaml 与各层均无)' };
418
+ }
419
+
420
+ const candidates = [join(dshHome, '.agent-presets', presetId, 'agent.cordis.yml')];
421
+ const apDir = resolveBundleDir('@deepseek-ai/dsh-agent-presets', profileDir, cliRoots);
422
+ if (apDir) candidates.push(join(apDir, 'presets', presetId, 'agent.cordis.yml'));
423
+
424
+ let file = null;
425
+ for (const c of candidates) {
426
+ if (readText(c) !== null) { file = c; break; }
427
+ }
428
+ if (file === null) {
429
+ return { known: false, reason: `agent preset "${presetId}" 的组合文件 agent.cordis.yml 未找到(${origin})` };
430
+ }
431
+
432
+ const content = readText(file);
433
+ const block = rowBlock(content, 'tool-presentation');
434
+ if (block === null) {
435
+ return { known: true, value: 'native', source: `${file}: 无 tool-presentation 行 → native`, presetId, origin };
436
+ }
437
+ const cfg = configSection(block);
438
+ const raw = cfg ? scalarAt(cfg, 'mode') : undefined;
439
+ const ev = evalScalar(raw, env);
440
+ if (!ev.known) {
441
+ return { known: false, reason: `agent preset "${presetId}" 的 tool-presentation.config.mode 非静态可解析(${ev.reason})` };
442
+ }
443
+ const value = ev.value === undefined ? 'native' : ev.value;
444
+ if (!TOOLS_MODES.has(value)) {
445
+ return { known: false, reason: `agent preset "${presetId}" 的 tool-presentation.config.mode="${value}" 不是已知取值` };
446
+ }
447
+ return { known: true, value, source: `${file}: tool-presentation.config.mode=${value}`, presetId, origin };
448
+ }
449
+
450
+ /** 把 provenance 渲染成人类可读的一句来源说明。 */
451
+ function describeValue(res, extra = '') {
452
+ const bits = [];
453
+ if (res.layer) bits.push(`来源 ${res.layer.path}${res.layer.name ? `(layer: ${res.layer.name})` : ''}`);
454
+ if (res.envName) {
455
+ bits.push(res.envUnset
456
+ ? `环境变量 ${res.envName} 未设置 → 落到 schema 默认值`
457
+ : res.envFellBack
458
+ ? `环境变量 ${res.envName} 未设置 → 落到表达式默认值`
459
+ : `环境变量 ${res.envName}=${res.value}`);
460
+ }
461
+ if (res.fromSchemaDefault && !res.envName) bits.push('schema 默认值');
462
+ if (extra) bits.push(extra);
463
+ return bits.join(';');
464
+ }
465
+
466
+ /* ────────────────────────── 检查主体 ────────────────────────── */
467
+
468
+ /**
469
+ * SP13 检查:Code Mode × 限制性沙箱错配(#3245)。
470
+ * @param {string} profileDir - profile 目录(doctor 传入)
471
+ * @param {{env?: object, dshHome?: string, cliRoots?: string[]}} [opts] - 测试注入点
472
+ * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
473
+ */
474
+ export async function run(profileDir, opts = {}) {
475
+ const env = opts.env ?? process.env;
476
+
477
+ if (!profileDir || typeof profileDir !== 'string' || !existsSync(profileDir)) {
478
+ return skip(ID, Severity.CRITICAL, `无法确定 profile 目录(收到 ${JSON.stringify(profileDir)}),跳过 Code Mode × 沙箱错配检查`);
479
+ }
480
+
481
+ const dshHome = opts.dshHome ?? inferDshHome(profileDir, env);
482
+ const cliRoots = opts.cliRoots ?? resolveCliRoots(env);
483
+ const { layers } = collectLayers(profileDir, cliRoots);
484
+
485
+ if (layers.length === 0) {
486
+ return skip(ID, Severity.CRITICAL,
487
+ '未找到任何可读的 patch 层(profile package.json 的 dsh.profile.bundles 无法解析,profile 自身也无 cordis.patch.yml),无法确定 tools / sandbox mode');
488
+ }
489
+
490
+ const tools = resolveRow(layers, 'tools', env, 'native');
491
+ const sandbox = resolveRow(layers, 'sandbox-policy', env, 'read-only');
492
+ const presets = resolvePresets(layers);
493
+ const settings = readSettings(dshHome);
494
+ const agentPreset = resolveAgentPresetMode(settings, layers, profileDir, dshHome, cliRoots, env);
495
+
496
+ /* ---- tools mode ---- */
497
+ if (tools.status === 'missing') {
498
+ return skip(ID, Severity.CRITICAL,
499
+ `已扫描 ${layers.length} 个 patch 层,但没有任何一层声明 \`tools\` 行,无法确定 tools mode(不猜)`);
500
+ }
501
+ if (tools.status === 'unknown') {
502
+ return skip(ID, Severity.CRITICAL,
503
+ `\`tools\` 行的 config.mode 不是静态可解析的标量(${tools.reason}),无法确定 tools mode(不猜)`
504
+ + (tools.layer ? `;来源 ${tools.layer.path}` : ''));
505
+ }
506
+ if (tools.status === 'disabled') {
507
+ return pass(ID, Severity.CRITICAL,
508
+ `\`tools\` 行被禁用(来源 ${tools.layer ? tools.layer.path : '?'}),进程内不存在 tools 注册表 → 无 Code Mode 执行面`);
509
+ }
510
+ if (!TOOLS_MODES.has(tools.value)) {
511
+ return skip(ID, Severity.CRITICAL,
512
+ `\`tools\` 行 config.mode="${tools.value}" 不在已知取值 {native, ptc, both} 内,无法确定 tools mode(不猜)`
513
+ + (tools.layer ? `;来源 ${tools.layer.path}` : ''));
514
+ }
515
+
516
+ const deploymentCodeMode = CODE_MODES.has(tools.value);
517
+ const agentCodeMode = agentPreset.known && CODE_MODES.has(agentPreset.value);
518
+
519
+ /* ---- sandbox mode(含 settings.yaml permission.defaultPreset 覆盖)---- */
520
+ let sandboxValue = null;
521
+ let sandboxSource = '';
522
+
523
+ if (sandbox.status === 'value') {
524
+ if (!SANDBOX_MODES.has(sandbox.value)) {
525
+ return skip(ID, Severity.CRITICAL,
526
+ `\`sandbox-policy\` 行 config.mode="${sandbox.value}" 不在已知取值 {read-only, workspace-write, danger-full-access} 内,无法确定 sandbox mode(不猜)`
527
+ + (sandbox.layer ? `;来源 ${sandbox.layer.path}` : ''));
528
+ }
529
+ sandboxValue = sandbox.value;
530
+ sandboxSource = describeValue(sandbox);
531
+ } else if (sandbox.status === 'unknown') {
532
+ return skip(ID, Severity.CRITICAL,
533
+ `\`sandbox-policy\` 行的 config.mode 不是静态可解析的标量(${sandbox.reason}),无法确定 sandbox mode(不猜)`
534
+ + (sandbox.layer ? `;来源 ${sandbox.layer.path}` : ''));
535
+ } else if (sandbox.status === 'disabled') {
536
+ return skip(ID, Severity.CRITICAL,
537
+ `\`sandbox-policy\` 行被禁用(来源 ${sandbox.layer ? sandbox.layer.path : '?'}),无法确定生效沙箱模式(不猜)`);
538
+ } else {
539
+ // missing:只有 settings.yaml 的 permission.defaultPreset 仍能给出答案
540
+ sandboxValue = null;
541
+ }
542
+
543
+ const permRowPresent = layers.some((l) => rowBlock(l.content, 'permission') !== null);
544
+ const presetSetting = settings.sections.permission && settings.sections.permission.defaultPreset;
545
+ if (permRowPresent && presetSetting) {
546
+ const spec = presets[presetSetting];
547
+ if (!spec || !spec.sandbox || !SANDBOX_MODES.has(spec.sandbox)) {
548
+ return skip(ID, Severity.CRITICAL,
549
+ `settings.yaml permission.defaultPreset="${presetSetting}" 在 preset 表中没有可用的 sandbox 映射(已知:${Object.keys(presets).join(', ')}),无法确定 sandbox mode(不猜)`);
550
+ }
551
+ sandboxValue = spec.sandbox;
552
+ sandboxSource = `${settings.path}: permission.defaultPreset=${presetSetting} → presets.${presetSetting}.sandbox=${spec.sandbox}`;
553
+ }
554
+
555
+ if (sandboxValue === null) {
556
+ return skip(ID, Severity.CRITICAL,
557
+ `已扫描 ${layers.length} 个 patch 层,但没有任何一层声明 \`sandbox-policy\` 行`
558
+ + `(settings.yaml 也没有可用的 permission.defaultPreset),无法确定 sandbox mode(不猜)`);
559
+ }
560
+
561
+ /* ---- agent preset 面:部署层 native 时它才是决定项 ---- */
562
+ if (!deploymentCodeMode && !agentPreset.known) {
563
+ return skip(ID, Severity.CRITICAL,
564
+ `tools mode = native 且 agent preset 的 presentation 无法确定(${agentPreset.reason}),无法排除 per-agent Code Mode(不猜)`);
565
+ }
566
+
567
+ const codeMode = deploymentCodeMode || agentCodeMode;
568
+ const restrictive = RESTRICTIVE_SANDBOX.has(sandboxValue);
569
+
570
+ const toolsLine = deploymentCodeMode
571
+ ? `tools mode = "${tools.value}"(${describeValue(tools)})`
572
+ : agentCodeMode
573
+ ? `tools mode = native(部署层),但 agent preset "${agentPreset.presetId}" 的 per-agent presentation = "${agentPreset.value}"(${agentPreset.source})`
574
+ : `tools mode = "${tools.value}"(${describeValue(tools)}${agentPreset.known ? `;agent preset "${agentPreset.presetId}" presentation = "${agentPreset.value}"` : ''})`;
575
+ const sandboxLine = `sandbox mode = "${sandboxValue}"(${sandboxSource})`;
576
+
577
+ if (!codeMode) {
578
+ return pass(ID, Severity.CRITICAL,
579
+ `Code Mode 未启用,无 #3245 错配:${toolsLine};${sandboxLine}`);
580
+ }
581
+
582
+ if (!restrictive) {
583
+ return pass(ID, Severity.CRITICAL,
584
+ `Code Mode 已启用但沙箱为 danger-full-access,无沙箱可绕过(#3245 不适用):${toolsLine};${sandboxLine}`);
585
+ }
586
+
587
+ return fail(ID, Severity.CRITICAL,
588
+ `Code Mode(PTC)在限制性沙箱下运行 —— run_code 绕过文件效应沙箱,模型代码获得完整宿主文件/进程权限(#3245,rc.7/rc.8 双重独立复现):\n`
589
+ + ` • ${toolsLine}\n`
590
+ + ` • ${sandboxLine}\n`
591
+ + `两个取值冲突:tools mode ∈ {ptc, both} 而 sandbox mode ∈ {read-only, workspace-write}。\n`
592
+ + `run_code 的执行体是 worker 线程(@deepseek-ai/dsh-code-runtime-worker-thread),不经过 ctx.sandbox.confine():`
593
+ + `程序可 import('node:fs') / node:child_process,读写在沙箱外的宿主任意路径并启动进程;`
594
+ + `操作者以为生效的文件效应沙箱对 Code Mode 不适用。`,
595
+ '三选一:(1) 把 tools mode 切回 native —— 取消 DSH_TOOLS_MODE(或显式设为 native),并检查没有任何 agent preset 声明 tool-presentation.config.mode: ptc/both;'
596
+ + '(2) 在知情前提下把沙箱显式设为 danger-full-access(DSH_PERMISSION_MODE=danger-full-access 或 settings.yaml permission.defaultPreset: danger-full-access),不再假装有沙箱;'
597
+ + '(3) 换用可被沙箱约束的 code runtime(当前 worker-thread runtime 无 file-effect policy)',
598
+ ['#3245']
599
+ );
600
+ }
601
+
602
+ export const sp13Check = {
603
+ id: 'SP13',
604
+ name: 'tools-mode-sandbox-mismatch',
605
+ severity: Severity.CRITICAL,
606
+ phase: CheckPhase.POST_INSTALL,
607
+ description: 'Code Mode(tools mode = ptc/both)× 限制性沙箱错配——run_code 绕过文件效应沙箱(#3245)',
608
+ src: 'builtin',
609
+ runner: (profileDir) => run(profileDir),
610
+ };
611
+
612
+ /** 测试用内部函数导出(非公开 API)。 */
613
+ export const __internal = {
614
+ rowBlock,
615
+ configSection,
616
+ subSection,
617
+ scalarAt,
618
+ evalScalar,
619
+ inferDshHome,
620
+ resolveCliRoots,
621
+ resolveBundleDir,
622
+ collectLayers,
623
+ resolveRow,
624
+ resolvePresets,
625
+ readSettings,
626
+ resolveAgentPresetMode,
627
+ TOOLS_MODES,
628
+ CODE_MODES,
629
+ SANDBOX_MODES,
630
+ BASE_PRESETS,
631
+ };