@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.
@@ -1,210 +1,356 @@
1
1
  /**
2
2
  * SP3: Sandbox Consistency — 沙箱策略配置一致性审计
3
3
  *
4
- * 轻量版 dsh-sandbox-audit:检查 cordis.patch.yml 中的沙箱策略配置,
5
- * 检测工具的沙箱接线与策略声明不一致。
4
+ * 2026-09 上游兼容审计 R6 修复:真实的 sandbox/approval/permission 配置
5
+ * **不在 profile 里**,而在 CLI 自带 bundle 的 patch 层与用户设置里:
6
+ * 1. `@deepseek-ai/dsh-base/cordis.patch.yml`(经安装树解析)
7
+ * - `id: sandbox-policy` → `config.mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'`
8
+ * - `id: approval` → `config.policy`(mode 为 danger-full-access 时 never,否则 ask)
9
+ * - `id: permission` → `config.presets.{read-only,workspace-write,danger-full-access}`
10
+ * 2. `$DSH_HOME/settings.yaml` → `permission.defaultPreset`
6
11
  *
7
- * 三大类问题:
8
- * HIGH: 变文件系统工具共享 bare fs-local backend(策略被静默忽略)
9
- * MEDIUM: 搜索工具未挂载 fs 但读取了写策略外的路径
10
- * LOW: 工具声明了不必要的沙箱权限
12
+ * 旧的 id 表已失效:`str_replace_editor` 是 tool *名* 而非 loader id,
13
+ * `tool-glob` / `tool-grep` / `tool-fs-write` 已不存在(并入 `tool-fs-search`)。
14
+ * 现在的 loader id 只有 `tool-fs` 与 `tool-fs-search`,沙箱由全局服务
15
+ * `sandbox` / `sandbox-policy` 承载,而非逐工具声明。
16
+ *
17
+ * 每个配置源缺失时返回 **skip + 原因**,绝不因为"没扫到"就 PASS。
11
18
  *
12
19
  * Severity: MEDIUM(默认)
13
20
  * Phase: POST_INSTALL
14
21
  */
15
22
 
16
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
17
- import { join } from 'node:path';
23
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { dirname, join } from 'node:path';
26
+ import { homedir } from 'node:os';
18
27
  import { Severity } from '../protocol/severity.mjs';
19
28
  import { CheckPhase } from '../protocol/phase.mjs';
20
- import { pass, fail } from '../protocol/check.mjs';
21
-
22
- /** 已知的变文件系统工具(需要 sandbox 接线) */
23
- const MUTATING_FS_TOOLS = [
24
- 'tool-fs',
25
- 'str_replace_editor',
26
- 'tool-fs-write',
27
- ];
28
-
29
- /** 已知的搜索工具(只读,但可能越权读取) */
30
- const SEARCH_TOOLS = [
31
- 'tool-fs-search',
32
- 'tool-glob',
33
- 'tool-grep',
34
- ];
29
+ import { pass, fail, skip } from '../protocol/check.mjs';
30
+
31
+ /** 真实存在的变文件系统工具 loader id(`str_replace_editor` 是 tool 名,不是 loader id) */
32
+ export const MUTATING_FS_TOOL_IDS = ['tool-fs'];
33
+
34
+ /** 真实存在的只读搜索工具 loader id(`tool-glob` / `tool-grep` 已并入它) */
35
+ export const READONLY_SEARCH_TOOL_IDS = ['tool-fs-search'];
36
+
37
+ /** 承载沙箱/审批/权限的真实服务 id */
38
+ export const SANDBOX_SERVICE_IDS = ['sandbox', 'sandbox-policy', 'bash-sandbox', 'pwsh-sandbox', 'approval', 'permission'];
35
39
 
36
40
  /**
37
- * cordis.patch.yml 内容中提取所有 entry id
41
+ * 解析 CLI 安装树里的 dsh-base bundle patch。
42
+ * 顺序:显式覆盖 → profile 内 → module-fallback 锚点 → `which dsh` 指向的全局安装树。
43
+ * @returns {string|null}
38
44
  */
39
- function extractEntryIds(content) {
40
- const ids = [];
41
- const regex = /^\s*-?\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/gm;
42
- let match;
43
- while ((match = regex.exec(content)) !== null) {
44
- ids.push(match[1]);
45
- }
46
- return ids;
45
+ export function resolveDshBasePatch(profileDir) {
46
+ const candidates = [];
47
+ if (process.env.DSH_BASE_PATCH) candidates.push(process.env.DSH_BASE_PATCH);
48
+ if (profileDir) {
49
+ candidates.push(join(profileDir, 'node_modules', '@deepseek-ai', 'dsh-base', 'cordis.patch.yml'));
50
+ candidates.push(join(profileDir, '.dsh-module-fallback', 'node_modules', '@deepseek-ai', 'dsh-base', 'cordis.patch.yml'));
51
+ }
52
+ for (const candidate of candidates) {
53
+ if (candidate && existsSync(candidate)) return candidate;
54
+ }
55
+
56
+ // 全局安装树:<prefix>/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-base/
57
+ let whichRes;
58
+ try {
59
+ whichRes = spawnSync('which', ['dsh'], { encoding: 'utf8' });
60
+ } catch {
61
+ return null;
62
+ }
63
+ const binPath = ((whichRes && whichRes.stdout) || '').trim().split('\n').filter(Boolean)[0];
64
+ if (!binPath) return null;
65
+
66
+ let real = binPath;
67
+ try { real = realpathSync(binPath); } catch { /* 用原路径 */ }
68
+
69
+ let dir = dirname(real);
70
+ for (let i = 0; i < 8; i++) {
71
+ const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh-base', 'cordis.patch.yml');
72
+ if (existsSync(candidate)) return candidate;
73
+ const parent = dirname(dir);
74
+ if (parent === dir) break;
75
+ dir = parent;
76
+ }
77
+ return null;
47
78
  }
48
79
 
49
- /**
50
- * 获取指定 entry 的配置块(从 - id: 到下一个 - id: 之间的所有内容)
51
- */
52
- function getEntryBlock(content, entryId) {
53
- const entryRegex = new RegExp(`^\\s*-?\\s*id:\\s*['"]?${entryId}['"]?\\s*$`, 'gm');
54
- const entryMatch = entryRegex.exec(content);
55
- if (!entryMatch) return '';
56
-
57
- const afterEntry = content.slice(entryMatch.index + entryMatch[0].length);
58
- const nextEntryMatch = afterEntry.match(/^\s*-?\s*id:\s/m);
59
- return nextEntryMatch
60
- ? afterEntry.slice(0, nextEntryMatch.index)
61
- : afterEntry;
80
+ /** $DSH_HOME/settings.yaml(默认 ~/.dsh/settings.yaml) */
81
+ export function resolveSettingsPath() {
82
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim()
83
+ ? process.env.DSH_HOME.trim()
84
+ : join(homedir(), '.dsh');
85
+ return join(home, 'settings.yaml');
86
+ }
87
+
88
+ /** 去掉一层首尾匹配引号 */
89
+ function stripOuterQuotes(s) {
90
+ if (s.length >= 2 &&
91
+ ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"')))) {
92
+ return s.slice(1, -1);
93
+ }
94
+ return s;
95
+ }
96
+
97
+ function stripInlineComment(s) {
98
+ return s.replace(/\s+#.*$/, '').trim();
62
99
  }
63
100
 
64
101
  /**
65
- * 检查 entry 配置块中是否包含某个 key(任意层级)
102
+ * 解析 YAML 标量:支持 `!!js` 表达式(含 `??` 默认值与三元表达式)与普通带引号标量。
103
+ * 返回该表达式在"无环境覆盖"下的默认值;无法静态求值时返回 null。
66
104
  */
67
- function blockHasKey(block, key) {
68
- return new RegExp(`^\\s+${key}:\\s`, 'm').test(block);
105
+ export function resolveJsDefault(raw) {
106
+ if (raw === null || raw === undefined) return null;
107
+ let s = stripInlineComment(String(raw));
108
+ if (s.startsWith('!!js')) {
109
+ s = stripOuterQuotes(s.slice(4).trim());
110
+ // 三元表达式:取 else 分支(最后一个带引号的值)
111
+ if (s.includes('?') && s.includes(':')) {
112
+ const quoted = [...s.matchAll(/'([^']*)'|"([^"]*)"/g)].map(m => m[1] ?? m[2]);
113
+ if (quoted.length) return quoted[quoted.length - 1];
114
+ }
115
+ const nullish = s.match(/\?\?\s*'([^']*)'|\?\?\s*"([^"]*)"/);
116
+ if (nullish) return nullish[1] ?? nullish[2];
117
+ const quoted = [...s.matchAll(/'([^']*)'|"([^"]*)"/g)].map(m => m[1] ?? m[2]);
118
+ if (quoted.length) return quoted[quoted.length - 1];
119
+ return null;
120
+ }
121
+ return stripOuterQuotes(s) || null;
69
122
  }
70
123
 
71
124
  /**
72
- * 检查 sandbox-policy 配置
125
+ * patch 内容中切出 `- id: <x>` 条目块。
126
+ * @returns {Array<{id: string, block: string, disabled: boolean}>}
73
127
  */
74
- function checkSandboxPolicy(content) {
75
- const issues = [];
76
-
77
- // 只检查显式声明了 sandbox-policy 的情况
78
- if (!content.includes('sandbox-policy')) return issues;
79
-
80
- // 检查 danger-full-access 模式
81
- if (/mode:\s*['"]?danger-full-access['"]?/.test(content) &&
82
- !/mode:\s*.*win32/.test(content)) {
83
- issues.push({
84
- severity: 'medium',
85
- tool: 'sandbox-policy',
86
- finding: 'sandbox-policy 使用 danger-full-access 模式(非 Windows),所有工具不受沙箱限制',
87
- });
128
+ export function parseEntries(content) {
129
+ const re = /^[ \t]*-[ \t]*id:[ \t]*['"]?([^'"\n]+?)['"]?[ \t]*$/gm;
130
+ const matches = [];
131
+ let m;
132
+ while ((m = re.exec(content)) !== null) {
133
+ matches.push({ id: m[1].trim(), index: m.index, end: m.index + m[0].length });
88
134
  }
135
+ return matches.map((entry, i) => {
136
+ const block = content.slice(entry.end, i + 1 < matches.length ? matches[i + 1].index : content.length);
137
+ const disabledRaw = extractEntryScalar(block, 'disabled');
138
+ return { id: entry.id, block, disabled: disabledRaw === 'true' };
139
+ });
140
+ }
89
141
 
90
- return issues;
142
+ /** 在条目块里取一个顶层标量(如 `disabled:` / `mode:` / `policy:`) */
143
+ function extractEntryScalar(block, key) {
144
+ const m = block.match(new RegExp(`^[ \\t]+${key}:[ \\t]*(.+?)[ \\t]*$`, 'm'));
145
+ return m ? m[1] : null;
91
146
  }
92
147
 
93
- /**
94
- * 检查工具的沙箱接线
95
- */
96
- function checkToolSandbox(content) {
97
- const issues = [];
98
- const entryIds = extractEntryIds(content);
99
-
100
- // 检查变文件系统工具是否有 sandbox 接线
101
- for (const tool of MUTATING_FS_TOOLS) {
102
- if (entryIds.includes(tool)) {
103
- const block = getEntryBlock(content, tool);
104
- const hasSandbox = blockHasKey(block, 'sandbox') || blockHasKey(block, 'sandbox-backend');
105
- if (!hasSandbox) {
106
- issues.push({
107
- severity: 'medium',
108
- tool,
109
- finding: `${tool} 未声明沙箱后端配置,可能使用默认 bare fs-local(策略被静默忽略)`,
110
- });
111
- }
148
+ /** 解析 permission 条目的 `config.presets` 表 → { name: { sandbox, approval } } */
149
+ export function parsePresets(block) {
150
+ const presets = {};
151
+ const lines = block.split('\n');
152
+ const start = lines.findIndex(l => /^[ \t]*presets:[ \t]*$/.test(l));
153
+ if (start === -1) return presets;
154
+
155
+ const baseIndent = lines[start].match(/^([ \t]*)/)[1].length;
156
+ let current = null;
157
+ for (let i = start + 1; i < lines.length; i++) {
158
+ const line = lines[i];
159
+ if (!line.trim() || line.trim().startsWith('#')) continue;
160
+ const indent = line.match(/^([ \t]*)/)[1].length;
161
+ if (indent <= baseIndent) break;
162
+ const presetName = line.match(/^[ \t]+([A-Za-z0-9_.-]+):[ \t]*$/);
163
+ if (presetName && indent === baseIndent + 2) {
164
+ current = presetName[1];
165
+ presets[current] = {};
166
+ continue;
112
167
  }
168
+ const kv = line.match(/^[ \t]+([A-Za-z0-9_.-]+):[ \t]*(.+?)[ \t]*$/);
169
+ if (kv && current) presets[current][kv[1]] = resolveJsDefault(kv[2]);
113
170
  }
171
+ return presets;
172
+ }
114
173
 
115
- // 检查搜索工具是否挂载了 fs
116
- for (const tool of SEARCH_TOOLS) {
117
- if (entryIds.includes(tool)) {
118
- const block = getEntryBlock(content, tool);
119
- const hasFs = blockHasKey(block, 'fs');
120
- if (!hasFs) {
121
- issues.push({
122
- severity: 'low',
123
- tool,
124
- finding: `${tool} 未显式挂载 fs,可能读取写策略外的路径`,
125
- });
126
- }
174
+ /**
175
+ * settings.yaml 读取顶层 `permission.defaultPreset`。
176
+ * 找不到 `permission` 命名空间或该键时返回 null。
177
+ */
178
+ export function readDefaultPreset(content) {
179
+ const lines = String(content).split('\n');
180
+ let inPermission = false;
181
+ for (const line of lines) {
182
+ if (!line.trim() || line.trim().startsWith('#')) continue;
183
+ const indent = line.match(/^([ \t]*)/)[1].length;
184
+ if (/^permission:[ \t]*$/.test(line)) { inPermission = true; continue; }
185
+ if (inPermission) {
186
+ if (indent === 0) { inPermission = false; continue; }
187
+ const m = line.match(/^[ \t]+defaultPreset:[ \t]*(.+?)[ \t]*$/);
188
+ if (m) return resolveJsDefault(m[1]);
127
189
  }
128
190
  }
129
-
130
- return issues;
191
+ return null;
131
192
  }
132
193
 
133
194
  /**
134
- * SP3 检查:扫描 profile 中的沙箱策略一致性
195
+ * SP3 检查:沙箱/审批/权限解析配置一致性
135
196
  * @param {string} profileDir - profile 目录路径
136
197
  * @returns {Promise<import('../protocol/check.mjs').SecurityCheckResult>}
137
198
  */
138
199
  export async function run(profileDir) {
139
200
  const id = 'SP3';
201
+ const patchPath = resolveDshBasePatch(profileDir);
202
+ const settingsPath = resolveSettingsPath();
203
+ const settingsExists = existsSync(settingsPath);
140
204
 
141
- // 找所有 cordis.patch.yml 文件
142
- const patchFiles = [];
143
- const profilePatch = join(profileDir, 'cordis.patch.yml');
144
- if (existsSync(profilePatch)) patchFiles.push(profilePatch);
145
-
146
- // 扫描 node_modules 中的 bundle patch
147
- const nmDir = join(profileDir, 'node_modules');
148
- if (existsSync(nmDir)) {
149
- const entries = readdirSync(nmDir, { withFileTypes: true });
150
- for (const entry of entries) {
151
- if (entry.name.startsWith('.') || entry.name === '.bin') continue;
152
- if (entry.name.startsWith('@')) {
153
- // scoped package
154
- const scopeDir = join(nmDir, entry.name);
155
- const pkgs = readdirSync(scopeDir, { withFileTypes: true });
156
- for (const pkg of pkgs) {
157
- const patchPath = join(scopeDir, pkg.name, 'cordis.patch.yml');
158
- if (existsSync(patchPath)) patchFiles.push(patchPath);
159
- }
160
- } else {
161
- const patchPath = join(nmDir, entry.name, 'cordis.patch.yml');
162
- if (existsSync(patchPath)) patchFiles.push(patchPath);
163
- }
164
- }
205
+ if (!patchPath) {
206
+ const settingsNote = settingsExists
207
+ ? ';settings.yaml 存在但缺少 dsh-base bundle patch,无法解析 permission presets 表'
208
+ : ';settings.yaml 也不存在';
209
+ return skip(id, Severity.MEDIUM,
210
+ `未找到 @deepseek-ai/dsh-base/cordis.patch.yml(真实沙箱策略配置位置),跳过一致性审计${settingsNote}`);
165
211
  }
166
212
 
167
- if (patchFiles.length === 0) {
168
- return pass(id, Severity.MEDIUM, '未找到 cordis.patch.yml 配置文件,跳过沙箱策略审计');
213
+ let content;
214
+ try {
215
+ content = readFileSync(patchPath, 'utf8');
216
+ } catch (e) {
217
+ return skip(id, Severity.MEDIUM, `dsh-base patch 不可读(${patchPath}):${e.message},跳过一致性审计`);
218
+ }
219
+
220
+ const byId = new Map(parseEntries(content).filter(e => !e.disabled).map(e => [e.id, e]));
221
+ const sandboxPolicy = byId.get('sandbox-policy');
222
+ const approvalEntry = byId.get('approval');
223
+ const permissionEntry = byId.get('permission');
224
+
225
+ if (!sandboxPolicy && !approvalEntry && !permissionEntry) {
226
+ return skip(id, Severity.MEDIUM,
227
+ `dsh-base patch(${patchPath})中未找到 sandbox-policy/approval/permission 条目,沙箱配置源缺失,跳过一致性审计`);
169
228
  }
170
229
 
171
- const allIssues = [];
172
- for (const patchFile of patchFiles) {
230
+ const issues = [];
231
+ const envMode = (process.env.DSH_PERMISSION_MODE || '').trim() || null;
232
+ const patchMode = sandboxPolicy ? resolveJsDefault(extractEntryScalar(sandboxPolicy.block, 'mode')) : null;
233
+ const patchApproval = approvalEntry ? resolveJsDefault(extractEntryScalar(approvalEntry.block, 'policy')) : null;
234
+ const presets = permissionEntry ? parsePresets(permissionEntry.block) : {};
235
+
236
+ let settingsPreset = null;
237
+ if (settingsExists) {
173
238
  try {
174
- const content = readFileSync(patchFile, 'utf8');
175
- allIssues.push(...checkSandboxPolicy(content));
176
- allIssues.push(...checkToolSandbox(content));
239
+ settingsPreset = readDefaultPreset(readFileSync(settingsPath, 'utf8'));
177
240
  } catch {
178
- // 跳过无法解析的文件
241
+ settingsPreset = null;
242
+ }
243
+ }
244
+
245
+ let mode;
246
+ let approvalPolicy;
247
+ let source;
248
+
249
+ if (envMode) {
250
+ mode = envMode;
251
+ approvalPolicy = envMode === 'danger-full-access' ? 'never' : 'ask';
252
+ source = `DSH_PERMISSION_MODE=${envMode}(环境覆盖)`;
253
+ } else if (settingsPreset) {
254
+ if (presets[settingsPreset]) {
255
+ mode = presets[settingsPreset].sandbox ?? patchMode;
256
+ approvalPolicy = presets[settingsPreset].approval ?? patchApproval;
257
+ source = `settings.yaml permission.defaultPreset=${settingsPreset}`;
258
+ } else {
259
+ issues.push({
260
+ severity: 'medium',
261
+ tool: 'permission.defaultPreset',
262
+ finding: `settings.yaml 指定 permission.defaultPreset=${settingsPreset},但 dsh-base patch 的 presets 表中没有该预设(可选:${Object.keys(presets).join(', ') || '无'})`,
263
+ });
264
+ mode = patchMode;
265
+ approvalPolicy = patchApproval;
266
+ source = 'dsh-base patch 默认值(defaultPreset 无法解析)';
267
+ }
268
+ } else {
269
+ mode = patchMode;
270
+ approvalPolicy = patchApproval;
271
+ source = 'dsh-base patch 默认值';
272
+ }
273
+
274
+ // 1) 沙箱被解析为完全放行(非 Windows)
275
+ if (mode === 'danger-full-access' && process.platform !== 'win32') {
276
+ issues.push({
277
+ severity: 'medium',
278
+ tool: 'sandbox-policy',
279
+ finding: `沙箱模式解析为 danger-full-access(来源:${source}),非 Windows 下所有工具不受文件系统沙箱限制`
280
+ + (approvalPolicy === 'never' ? ',且审批策略为 never(无人工闸门)' : ''),
281
+ });
282
+ }
283
+
284
+ // 2) 审批闸门与沙箱模式不一致
285
+ if (approvalPolicy === 'never' && mode && mode !== 'danger-full-access') {
286
+ issues.push({
287
+ severity: 'medium',
288
+ tool: 'approval',
289
+ finding: `审批策略解析为 never,但沙箱模式为 ${mode}(来源:${source}),二者不一致——审批闸门缺失而沙箱并未完全放行`,
290
+ });
291
+ }
292
+
293
+ // 3) 解析出的 (sandbox, approval) 组合不对应任何已声明预设
294
+ const presetNames = Object.keys(presets);
295
+ if (mode && approvalPolicy && presetNames.length > 0 &&
296
+ !presetNames.some(n => presets[n].sandbox === mode && presets[n].approval === approvalPolicy)) {
297
+ issues.push({
298
+ severity: 'low',
299
+ tool: 'permission.presets',
300
+ finding: `解析出的组合 sandbox=${mode} / approval=${approvalPolicy}(来源:${source})不对应 presets 表中的任何预设(${presetNames.join(', ')})`,
301
+ });
302
+ }
303
+
304
+ // 4) 工具接线:工具 id 存在但全局沙箱服务缺失
305
+ const presentSandboxServices = SANDBOX_SERVICE_IDS.filter(serviceId => byId.has(serviceId));
306
+ const hasSandboxService = presentSandboxServices.includes('sandbox') || presentSandboxServices.includes('sandbox-policy');
307
+ for (const toolId of MUTATING_FS_TOOL_IDS) {
308
+ if (byId.has(toolId) && !hasSandboxService) {
309
+ issues.push({
310
+ severity: 'medium',
311
+ tool: toolId,
312
+ finding: `${toolId} 已挂载,但 bundle 中没有 sandbox/sandbox-policy 服务,文件系统工具可能使用 bare backend(策略被静默忽略)`,
313
+ });
314
+ }
315
+ }
316
+ for (const toolId of READONLY_SEARCH_TOOL_IDS) {
317
+ if (byId.has(toolId) && !hasSandboxService) {
318
+ issues.push({
319
+ severity: 'low',
320
+ tool: toolId,
321
+ finding: `${toolId} 已挂载,但 bundle 中没有 sandbox/sandbox-policy 服务,只读搜索可能读取写策略外的路径`,
322
+ });
179
323
  }
180
324
  }
181
325
 
182
- if (allIssues.length === 0) {
183
- return pass(id, Severity.MEDIUM, `扫描 ${patchFiles.length} 个 patch 文件,沙箱策略配置一致`);
326
+ const sourceNote = [
327
+ `patch=${patchPath}`,
328
+ `settings=${settingsExists ? settingsPath : '不存在'}`,
329
+ `mode=${mode ?? '未知'}`,
330
+ `approval=${approvalPolicy ?? '未知'}`,
331
+ `sandboxServices=${presentSandboxServices.join('+') || '无'}`,
332
+ ].join(', ');
333
+
334
+ if (issues.length === 0) {
335
+ return pass(id, Severity.MEDIUM,
336
+ `沙箱/审批/权限配置一致(${sourceNote};来源:${source})`);
184
337
  }
185
338
 
186
339
  const bySeverity = {};
187
- for (const issue of allIssues) {
340
+ for (const issue of issues) {
188
341
  if (!bySeverity[issue.severity]) bySeverity[issue.severity] = [];
189
342
  bySeverity[issue.severity].push(issue);
190
343
  }
344
+ const summary = Object.entries(bySeverity).map(([sev, items]) => `${sev}: ${items.length}`).join(', ');
345
+ const details = issues.slice(0, 10).map(i => `[${i.severity}] ${i.tool} — ${i.finding}`).join('\n');
191
346
 
192
- const summary = Object.entries(bySeverity)
193
- .map(([sev, items]) => `${sev}: ${items.length}`)
194
- .join(', ');
195
-
196
- const details = allIssues
197
- .slice(0, 10)
198
- .map(i => `[${i.severity}] ${i.tool} — ${i.finding}`)
199
- .join('\n');
200
-
201
- const overallSeverity = allIssues.some(i => i.severity === 'high') ? Severity.HIGH
202
- : allIssues.some(i => i.severity === 'medium') ? Severity.MEDIUM
347
+ const overallSeverity = issues.some(i => i.severity === 'high') ? Severity.HIGH
348
+ : issues.some(i => i.severity === 'medium') ? Severity.MEDIUM
203
349
  : Severity.LOW;
204
350
 
205
351
  return fail(id, overallSeverity,
206
- `检测到 ${allIssues.length} 个沙箱策略不一致(${summary}):\n${details}`,
207
- '参考 dsh-sandbox-audit 获取详细修复建议',
352
+ `检测到 ${issues.length} 个沙箱配置不一致(${summary};${sourceNote}):\n${details}`,
353
+ '核对 @deepseek-ai/dsh-base/cordis.patch.yml 的 sandbox-policy/approval/permission 与 $DSH_HOME/settings.yaml 的 permission.defaultPreset',
208
354
  ['#2066']
209
355
  );
210
356
  }
@@ -214,7 +360,7 @@ export const sp3Check = {
214
360
  name: 'sandbox-consistency',
215
361
  severity: Severity.MEDIUM,
216
362
  phase: CheckPhase.POST_INSTALL,
217
- description: '沙箱策略配置一致性审计',
363
+ description: '沙箱策略配置一致性审计(dsh-base bundle patch + settings.yaml)',
218
364
  src: 'builtin',
219
365
  runner: (profileDir) => run(profileDir),
220
366
  };