@moonquake2004/dsh-security 0.1.7 → 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,76 +1,290 @@
1
1
  /**
2
- * SP5: Permission Model — 插件权限声明验证
2
+ * SP5: Permission Model — 真实的插件能力面(不是不存在的 permissions 字段)
3
3
  *
4
- * 检查插件是否声明了所需权限(capability declarations):
5
- * - 文件系统访问范围
6
- * - 网络访问权限
7
- * - 进程执行权限
4
+ * 2026-09 上游兼容审计 R7——旧模型(v0.1.7 及以前)的门控是对的,模型是错的:
5
+ * `dsh.bundle = { patch: './cordis.patch.yml' }` 确实存在(值得继续门控),
6
+ * 但它**声称**要建模的 capability surface 并不存在——0.1.5 的插件 manifest
7
+ * **没有 per-plugin `permissions` / `capabilities` 字段**。
8
+ * 旧实现用 `cordis.patch.yml` 正文里的 `tool-fs|str_replace_editor` + `sandbox`
9
+ * 字符串启发式去"检测未声明权限",匹配不到任何真实声明,恒为 PASS。
10
+ *
11
+ * 真实的能力面是三处(全部可读、可机器判定):
12
+ * (a) `dsh.client.inject` + `dsh.client.platform`
13
+ * —— 插件向 client 运行时注入的宿主模块清单(跨进程能力面)
14
+ * (b) `dsh.compatibility.{dsh, dshReleases, profiles}`
15
+ * —— 声明支持的 core 版本范围与被允许的 profile
16
+ * (c) 宿主侧 `permission` settings 命名空间(`$DSH_HOME/settings.yaml`
17
+ * → `permission.defaultPreset`;schema 仅此一键,
18
+ * dsh-permission-presets/lib/index.js:24,121-123)
19
+ * + `@deepseek-ai/dsh-base/cordis.patch.yml` 的
20
+ * `sandbox-policy.config.mode` / `approval.config.policy` / `permission.config.presets`
21
+ *
22
+ * 因此本检查改为**如实报告每个插件声明的能力面,以及哪些声明缺失 = 默认不受约束**:
23
+ * - 声明了 `dsh.client.inject` 但既无 `dsh.compatibility` 也无 profile 限制
24
+ * → 跨进程能力面完全无版本/配置约束(MEDIUM)
25
+ * - `dsh.compatibility.dsh` 范围**排除**了安装闭包实际提供的 core 版本(MEDIUM)
26
+ * - `dsh.compatibility.profiles` 不含当前 profile(MEDIUM)
27
+ * - manifest 里出现 `permissions` / `capabilities` 键:宿主无此 schema,
28
+ * 声明不会被强制执行(MEDIUM,避免把"写了"误当成"受限")
29
+ * - 无法精确判定版本归属时**不猜**(node-semver 缺失 → 近似法只在确定时给结论)
8
30
  *
9
31
  * Severity: MEDIUM
10
32
  * Phase: POST_INSTALL
11
33
  */
12
34
 
13
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
35
+ import { readFileSync, existsSync } from 'node:fs';
14
36
  import { join } from 'node:path';
15
37
  import { Severity } from '../protocol/severity.mjs';
16
38
  import { CheckPhase } from '../protocol/phase.mjs';
17
- import { pass, fail } from '../protocol/check.mjs';
39
+ import { createResult, fail, skip } from '../protocol/check.mjs';
40
+ import {
41
+ listPackageDirs,
42
+ resolveDshHome,
43
+ resolveCoreVersions,
44
+ resolveInstallPrefix,
45
+ resolveSemver,
46
+ checkRange,
47
+ } from '../install-tree.mjs';
48
+ import { readPermissionSettings, readHostSandboxConfig, resolveBasePatch } from '../dsh-config.mjs';
49
+
50
+ /** 宿主 manifest 里**不存在**的能力声明键——出现即说明作者误以为它会被强制 */
51
+ const NONEXISTENT_DECL_KEYS = ['permissions', 'capabilities', 'allowedTools'];
52
+
53
+ /** CLI 安装根候选(用于定位 dsh-base 的 patch 与 semver) */
54
+ function cliRootCandidates(profileDir) {
55
+ const prefix = resolveInstallPrefix(profileDir);
56
+ return [prefix ? join(prefix, 'dsh') : null, prefix].filter(Boolean);
57
+ }
58
+
59
+ /** 读取宿主侧真实权限面 */
60
+ function readHostSurface(profileDir, options = {}) {
61
+ const dshHome = options.dshHome !== undefined ? options.dshHome : resolveDshHome(profileDir);
62
+ const cliRoots = cliRootCandidates(profileDir);
63
+ const basePatchPath = options.basePatchPath !== undefined
64
+ ? options.basePatchPath
65
+ : resolveBasePatch(profileDir, cliRoots);
66
+ return {
67
+ dshHome,
68
+ basePatchPath,
69
+ permission: readPermissionSettings(dshHome),
70
+ sandbox: readHostSandboxConfig(basePatchPath),
71
+ };
72
+ }
73
+
74
+ function describeHost(host) {
75
+ const parts = [];
76
+ if (host.permission.file && host.permission.present) {
77
+ parts.push(`settings.yaml permission.defaultPreset=${host.permission.defaultPreset ?? '(空)'}`);
78
+ } else {
79
+ parts.push(`settings.yaml 未配置 permission.defaultPreset(宿主按 sandbox/approval 默认值推断 preset)`);
80
+ }
81
+ if (!host.sandbox) {
82
+ parts.push(`未找到 dsh-base/cordis.patch.yml(无法读取 sandbox-policy/approval 实际配置)`);
83
+ return parts.join(';');
84
+ }
85
+ const mode = host.sandbox.sandboxMode;
86
+ const approval = host.sandbox.approvalPolicy;
87
+ const modeText = mode
88
+ ? (mode.expression
89
+ ? `!!js ${mode.expression}${mode.literal ? `(默认字面量 ${mode.literal})` : ''}`
90
+ : mode.literal)
91
+ : '(未声明)';
92
+ const apprText = approval
93
+ ? (approval.expression && !approval.decisive ? '由沙箱模式派生(danger-full-access → never,否则 ask)' : (approval.literal ?? '(空)'))
94
+ : '(未声明)';
95
+ parts.push(`sandbox-policy.mode=${modeText}`);
96
+ parts.push(`approval.policy=${apprText}`);
97
+ const presetNames = Object.keys(host.sandbox.presets || {});
98
+ if (presetNames.length > 0) {
99
+ parts.push(`presets=${presetNames.map(n => `${n}{sandbox:${host.sandbox.presets[n].sandbox},approval:${host.sandbox.presets[n].approval}}`).join(' ')}`);
100
+ }
101
+ return parts.join(';');
102
+ }
103
+
104
+ /** 单个插件的声明面 */
105
+ function inspectPlugin(pkgJsonPath, displayName, ctx) {
106
+ let pkg;
107
+ try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); } catch { return null; }
108
+ const dsh = pkg && pkg.dsh;
109
+ if (!dsh || typeof dsh !== 'object') return null;
110
+
111
+ const declared = {
112
+ name: pkg.name || displayName,
113
+ bundlePatch: null,
114
+ clientInject: [],
115
+ clientPlatform: null,
116
+ compatDsh: null,
117
+ compatReleases: [],
118
+ compatProfiles: [],
119
+ unknownKeys: [],
120
+ };
121
+
122
+ if (dsh.bundle && dsh.bundle.patch) declared.bundlePatch = String(dsh.bundle.patch);
123
+ if (dsh.client && typeof dsh.client === 'object') {
124
+ if (Array.isArray(dsh.client.inject)) declared.clientInject = dsh.client.inject.filter(x => typeof x === 'string');
125
+ if (typeof dsh.client.platform === 'string') declared.clientPlatform = dsh.client.platform;
126
+ }
127
+ if (dsh.compatibility && typeof dsh.compatibility === 'object') {
128
+ if (typeof dsh.compatibility.dsh === 'string') declared.compatDsh = dsh.compatibility.dsh;
129
+ if (dsh.compatibility.dshReleases && typeof dsh.compatibility.dshReleases === 'object') {
130
+ declared.compatReleases = Object.keys(dsh.compatibility.dshReleases);
131
+ }
132
+ if (Array.isArray(dsh.compatibility.profiles)) {
133
+ declared.compatProfiles = dsh.compatibility.profiles.filter(x => typeof x === 'string');
134
+ }
135
+ }
136
+ for (const k of NONEXISTENT_DECL_KEYS) {
137
+ if (Object.prototype.hasOwnProperty.call(dsh, k)) declared.unknownKeys.push(k);
138
+ }
18
139
 
19
- function scanForUndeclaredCapabilities(content, pkgName) {
20
- const issues = [];
21
- // 检查文件系统访问但无 sandbox 声明
22
- if (/tool-fs|str_replace_editor/.test(content) && !/sandbox/.test(content)) {
23
- issues.push({ type: 'fs-without-sandbox', severity: 'medium', detail: `${pkgName} 使用文件系统工具但未声明 sandbox 配置` });
140
+ const findings = [];
141
+ const dshRoot = ctx.coreVersions['@deepseek-ai/dsh'];
142
+ const dshBase = ctx.coreVersions['@deepseek-ai/dsh-base'];
143
+ // 以 profile 侧安装闭包实际提供的版本为准(dsh-base profile 的 bundle 根),
144
+ // CLI 自身那份仅作兜底——两者同属安装闭包,都会在诊断里如实列出。
145
+ const provided = dshBase?.version || dshRoot?.version || null;
146
+
147
+ if (declared.unknownKeys.length > 0) {
148
+ findings.push({
149
+ severity: 'medium',
150
+ type: 'unrecognized-capability-declaration',
151
+ detail: `${declared.name} 的 manifest 声明了 dsh.${declared.unknownKeys.join('/dsh.')}——` +
152
+ `该字段在 0.1.5 宿主 manifest schema 中不存在,声明不会被强制执行(若作者以此认定权限已受限,则属于误信)`,
153
+ });
154
+ }
155
+
156
+ if (declared.compatDsh && provided) {
157
+ const r = checkRange(provided, declared.compatDsh, ctx.semverMod);
158
+ if (r.satisfies === false) {
159
+ findings.push({
160
+ severity: 'medium',
161
+ type: 'declared-core-range-excludes-provided',
162
+ detail: `${declared.name} 声明 dsh 兼容范围「${declared.compatDsh}」,` +
163
+ `但安装闭包实际提供 ${provided}${r.exact ? '' : '(近似判定,node-semver 不可用)'}——该插件未声明支持当前 core 版本`,
164
+ });
165
+ }
166
+ }
167
+
168
+ if (declared.compatProfiles.length > 0 && ctx.profileName && !declared.compatProfiles.includes(ctx.profileName)) {
169
+ findings.push({
170
+ severity: 'medium',
171
+ type: 'profile-not-declared',
172
+ detail: `${declared.name} 的 dsh.compatibility.profiles=[${declared.compatProfiles.join(', ')}] 不含当前 profile「${ctx.profileName}」`,
173
+ });
24
174
  }
25
- // 检查网络访问但无声明
26
- if (/fetch|http\.request|curl/.test(content) && !/network|http/.test(content)) {
27
- issues.push({ type: 'network-undeclared', severity: 'low', detail: `${pkgName} 有网络访问但未显式声明` });
175
+
176
+ if (declared.clientInject.length > 0 && !declared.compatDsh && declared.compatProfiles.length === 0) {
177
+ findings.push({
178
+ severity: 'medium',
179
+ type: 'cross-process-surface-unconstrained',
180
+ detail: `${declared.name} 向 client 运行时注入 ${declared.clientInject.length} 个宿主模块` +
181
+ `(${declared.clientInject.slice(0, 4).join(', ')}${declared.clientInject.length > 4 ? ' …' : ''}),` +
182
+ `却既无 dsh.compatibility.dsh 也无 profiles 限制——该跨进程能力面对 core 版本/配置完全无约束声明`,
183
+ });
28
184
  }
29
- return issues;
185
+
186
+ if (!declared.bundlePatch && !dsh.client) {
187
+ // 无 bundle patch 且无 client 注入:该包不是可装载插件,不参与能力面统计
188
+ return { declared, findings, plugin: false };
189
+ }
190
+ return { declared, findings, plugin: true };
30
191
  }
31
192
 
32
- export async function run(profileDir) {
193
+ export async function run(profileDir, options = {}) {
33
194
  const id = 'SP5';
34
195
  const nmDir = join(profileDir, 'node_modules');
35
- if (!existsSync(nmDir)) return pass(id, Severity.MEDIUM, '无 node_modules,跳过权限验证');
36
-
37
- const issues = [];
38
- for (const entry of readdirSync(nmDir, { withFileTypes: true })) {
39
- if (entry.name.startsWith('.') || entry.name === '.bin') continue;
40
- // scoped 包(@scope/pkg):复审修复——此前把 @scope 当包名拼路径,scoped 插件全部漏扫
41
- if (entry.isDirectory() && entry.name.startsWith('@')) {
42
- const scopeDir = join(nmDir, entry.name);
43
- let pkgs = [];
44
- try { pkgs = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
45
- for (const pkg of pkgs) {
46
- if (!pkg.isDirectory()) continue;
47
- issues.push(...inspectPackage(join(scopeDir, pkg.name), join(entry.name, pkg.name)));
48
- }
49
- continue;
50
- }
51
- if (!entry.isDirectory()) continue;
52
- issues.push(...inspectPackage(join(nmDir, entry.name), entry.name));
196
+ if (!existsSync(nmDir)) {
197
+ return skip(id, Severity.MEDIUM,
198
+ `无 node_modules(${nmDir}),未执行插件能力面审计——无法读取任何 manifest 声明`);
53
199
  }
54
200
 
55
- function inspectPackage(pkgDir, displayName) {
56
- const found = [];
57
- const pkgPath = join(pkgDir, 'package.json');
58
- if (!existsSync(pkgPath)) return found;
59
- try {
60
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
61
- if (!pkg.dsh?.bundle) return found;
62
- const patchPath = join(pkgDir, 'cordis.patch.yml');
63
- if (existsSync(patchPath)) {
64
- const content = readFileSync(patchPath, 'utf8');
65
- found.push(...scanForUndeclaredCapabilities(content, pkg.name || displayName));
66
- }
67
- } catch { /* skip */ }
68
- return found;
201
+ const host = readHostSurface(profileDir, options);
202
+ // SP9/安装树解析出的实际 profile 名参与 profiles 一致性判定
203
+ const layoutName = (() => {
204
+ const m = /[/\\]profiles[/\\]([^/\\]+)$/.exec(String(profileDir));
205
+ return m ? m[1] : null;
206
+ })();
207
+
208
+ const ctx = {
209
+ coreVersions: resolveCoreVersions(profileDir),
210
+ semverMod: options.semverMod !== undefined
211
+ ? options.semverMod
212
+ : (resolveSemver(profileDir, cliRootCandidates(profileDir))?.mod ?? null),
213
+ profileName: options.profileName !== undefined ? options.profileName : layoutName,
214
+ };
215
+
216
+ const dirs = listPackageDirs(nmDir);
217
+ const plugins = [];
218
+ const findings = [];
219
+ for (const entry of dirs) {
220
+ const pkgJson = join(entry.dir, 'package.json');
221
+ if (!existsSync(pkgJson)) continue;
222
+ const res = inspectPlugin(pkgJson, entry.name, ctx);
223
+ if (!res) continue;
224
+ if (res.plugin) plugins.push(res.declared);
225
+ findings.push(...res.findings);
226
+ }
227
+
228
+ if (plugins.length === 0) {
229
+ return skip(id, Severity.MEDIUM,
230
+ `${nmDir} 下未找到任何带 dsh 声明的插件包(0 个),未执行能力面审计——无法判定声明覆盖情况` +
231
+ `\n宿主侧真实权限面: ${describeHost(host)}`);
232
+ }
233
+
234
+ const declaredCount = plugins.filter(p => p.compatDsh || p.compatProfiles.length > 0).length;
235
+ const unconstrained = plugins.filter(p => !p.compatDsh && p.compatProfiles.length === 0);
236
+ const hostLine = `宿主侧真实权限面: ${describeHost(host)}`;
237
+ const envOverride = ['DSH_PERMISSION_MODE', 'DSH_TOOLS_MODE'].filter(k => process.env[k]);
238
+ const envLine = envOverride.length > 0
239
+ ? `当前进程环境覆盖: ${envOverride.map(k => `${k}=${process.env[k]}`).join(', ')}(运行期可覆盖上面 written config)`
240
+ : `当前进程无 DSH_PERMISSION_MODE / DSH_TOOLS_MODE 覆盖`;
241
+ const coreLine = `安装闭包提供: @deepseek-ai/dsh=${ctx.coreVersions['@deepseek-ai/dsh']?.version || '?'}` +
242
+ `, dsh-base=${ctx.coreVersions['@deepseek-ai/dsh-base']?.version || '?'}` +
243
+ (ctx.semverMod ? '(版本范围判定用 node-semver)' : '(node-semver 不可用,仅近似判定,不确定一律不报)');
244
+
245
+ const perPlugin = plugins.map(p => {
246
+ const bits = [];
247
+ if (p.bundlePatch) bits.push(`bundle.patch=${p.bundlePatch}`);
248
+ if (p.clientInject.length > 0) bits.push(`client.inject=${p.clientInject.length}${p.clientPlatform ? `/${p.clientPlatform}` : ''}`);
249
+ else if (p.clientPlatform) bits.push(`client.platform=${p.clientPlatform}`);
250
+ if (p.compatDsh) bits.push(`compat.dsh=${p.compatDsh}`);
251
+ else if (p.compatReleases.length > 0) bits.push(`compat.dshReleases=${p.compatReleases.length}`);
252
+ if (p.compatProfiles.length > 0) bits.push(`compat.profiles=[${p.compatProfiles.join(',')}]`);
253
+ if (p.unknownKeys.length > 0) bits.push(`dsh.${p.unknownKeys.join('/dsh.')}=(宿主无此 schema)`);
254
+ const constrained = p.compatDsh || p.compatProfiles.length > 0;
255
+ return ` ${p.name}: ${bits.join(' ') || '(仅 dsh 存在,无声明字段)'}${constrained ? '' : ' ← 无兼容/配置约束声明'}`;
256
+ }).join('\n');
257
+
258
+ const summaryLine =
259
+ `插件能力面: 共 ${plugins.length} 个带 dsh 声明的插件,其中 ${declaredCount} 个声明了 compat.dsh/profiles,` +
260
+ `${unconstrained.length} 个未做任何兼容/配置约束声明` +
261
+ (unconstrained.length > 0 ? `(${unconstrained.slice(0, 6).map(p => p.name).join(', ')}${unconstrained.length > 6 ? ' …' : ''})` : '');
262
+
263
+ const body = `${summaryLine}\n${hostLine}\n${envLine}\n${coreLine}\n逐插件声明:\n${perPlugin}`;
264
+
265
+ if (findings.length === 0) {
266
+ return createResult(id, true, Severity.MEDIUM,
267
+ `未发现能力面声明问题(未声明 ≠ 已受限:宿主默认按 preset/sandbox 配置执行)\n${body}`);
69
268
  }
70
269
 
71
- if (issues.length === 0) return pass(id, Severity.MEDIUM, '插件权限声明一致');
72
- const details = issues.slice(0, 10).map(i => `[${i.severity}] ${i.type}: ${i.detail}`).join('\n');
73
- return fail(id, Severity.MEDIUM, `检测到 ${issues.length} 个权限声明问题:\n${details}`, '为插件显式声明所需的权限范围');
270
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
271
+ const top = findings.reduce((a, b) => (order[b.severity] < order[a.severity] ? b : a), findings[0]);
272
+ const detail = findings.slice(0, 12).map(f => `[${f.severity}] ${f.type}: ${f.detail}`).join('\n');
273
+ const more = findings.length > 12 ? `\n…另有 ${findings.length - 12} 项` : '';
274
+ return fail(id, top.severity,
275
+ `检测到 ${findings.length} 项能力面声明问题:\n${detail}${more}\n${body}`,
276
+ '插件应在 manifest 里声明 dsh.compatibility.{dsh,profiles}(真实存在的字段);' +
277
+ '不要写 dsh.permissions/dsh.capabilities(宿主无此 schema,不会被强制);' +
278
+ '宿主侧用 settings.yaml 的 permission.defaultPreset 与 dsh-base 的 sandbox-policy/approval 收紧默认档位'
279
+ );
74
280
  }
75
281
 
76
- export const sp5Check = { id: 'SP5', name: 'permission-model', severity: Severity.MEDIUM, phase: CheckPhase.POST_INSTALL, description: '插件权限声明验证', src: 'builtin', runner: (d) => run(d) };
282
+ export const sp5Check = {
283
+ id: 'SP5',
284
+ name: 'permission-model',
285
+ severity: Severity.MEDIUM,
286
+ phase: CheckPhase.POST_INSTALL,
287
+ description: '插件能力面审计——读真实的 dsh.bundle / dsh.client.inject / dsh.compatibility 与宿主 permission/sandbox 配置(宿主无 per-plugin permissions 字段)',
288
+ src: 'builtin',
289
+ runner: (d) => run(d),
290
+ };
@@ -116,6 +116,22 @@ export async function run(profileDir) {
116
116
  `dist-tag 健康检查通过:${plugins.length} 个插件 / ${cache.size} 个 @deepseek-ai/dsh-* 包的 latest 均正常`);
117
117
  }
118
118
 
119
+ // 影响门控(2026-09 审计):0.1.5 起 profile **不再从 registry 安装** @deepseek-ai/* ——
120
+ // 实例来自 CLI 闭包(~/.dsh/profiles/node_modules/@deepseek-ai 是 dsh 自建的 symlink 镜像)。
121
+ // 此时 latest 卡在旧版不会影响已装实例,故只在 profile 里存在**真实目录**安装时才判为问题。
122
+ let installsFromRegistry = false;
123
+ try {
124
+ for (const e of readdirSync(join(profileDir, 'node_modules', '@deepseek-ai'), { withFileTypes: true })) {
125
+ if (e.isDirectory() && !e.isSymbolicLink()) { installsFromRegistry = true; break; }
126
+ }
127
+ } catch { /* 目录不存在 → 不从 registry 安装 */ }
128
+ if (!installsFromRegistry) {
129
+ return pass(id, Severity.HIGH,
130
+ `registry 的 @deepseek-ai/* latest 标签确有异常(${affected.length} 处 plugin×peer 版本对,涉及 ${new Set(affected.map((a) => a.plugin)).size} 个插件),`
131
+ + `但本 profile 的 @deepseek-ai/* 来自 CLI 闭包而非 registry 安装 → **对已装实例无影响**;`
132
+ + `仅在你日后执行未固定版本的 pnpm add 时会踩到(建议显式 pin 版本)`);
133
+ }
134
+
119
135
  const details = affected.slice(0, 15).map(a =>
120
136
  ` ${a.plugin} — peer ${a.peerPkg} ${a.range} → latest=${a.brokenLatest}(broken)`
121
137
  ).join('\n');