dsh-isolation-gate 0.0.0-stage → 0.2.1

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/lib/index.js ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * dsh-isolation-gate · HOST half(宿主半)
3
+ *
4
+ * 三件事:
5
+ * ① 把"门禁状态"暴露成本机 HTTP 路由,供 GUI 整页读写;
6
+ * ② **工具执行门**:`ctx.tools.guard()` —— 模型调用 read/write/edit/bash… 时,
7
+ * 在工具真正执行前按同一套三级判定拦下(2026-10-21 新增,本轮的核心缺口);
8
+ * ③ 原生「选择文件夹」桥:把宿主 `directoryPickerController.pick()` 暴露给面板
9
+ * (查不到就返回 501,面板自动退回"未入名单目录 + 文本框")。
10
+ *
11
+ * GET /__dsh-isolation-gate/ping 插件身份 + 路由清单 + 各级别说明 + 能力
12
+ * GET /__dsh-isolation-gate/state 全量状态:名单 / 级别 / 分组 / 磁盘差异 / 审计 / 策略行
13
+ * POST /__dsh-isolation-gate/level { name, level } 改一个对象的三级授权
14
+ * POST /__dsh-isolation-gate/roster { action, name } 名单增删(先备份;新增自动 deny)
15
+ * POST /__dsh-isolation-gate/group-level { group, level } 组级一键切
16
+ * POST /__dsh-isolation-gate/group { action:'assign'|'delete', name?, group } 归组 / 删组
17
+ * POST /__dsh-isolation-gate/prune 清理孤儿键
18
+ * POST /__dsh-isolation-gate/pick 调原生目录选择器(无则 501)
19
+ * POST /__dsh-isolation-gate/toolcheck { tool, args } 工具门干跑(不执行工具)
20
+ * POST /__dsh-isolation-gate/check { name?|path?, op } 受管辖判定(不读任何内容)
21
+ * GET /__dsh-isolation-gate/log?limit= 最近审计
22
+ *
23
+ * 服务访问照房规:`tools` 走 `inject` 硬声明;`webServer` / `directoryPickerController`
24
+ * 用 `ctx.get` 这个 cordis 官方安全取用口拿;拿不到就等 / 降级,**绝不探测未声明的服务**。
25
+ * 本包**不声明任何 `@deepseek-ai/dsh*` peer 依赖**(躲开兼容性豁免闸门)。
26
+ *
27
+ * 本宿主半**不会**读 / 写 / 列任何受管对象内部的内容:它只读写两个配置文件
28
+ * (状态 JSON + 名单 TXT)并列出 D 根**一层目录名**(外加对名单里**显式列出的路径**做一次
29
+ * 存在性判定)。受管对象内部的读写是 `bin/gate.mjs` 那条通道的活,那条通道同样受本插件管辖。
30
+ */
31
+
32
+ import { spawn } from 'node:child_process';
33
+ import {
34
+ assignGroup,
35
+ createGroup,
36
+ buildReport,
37
+ check,
38
+ checkTool,
39
+ deleteGroup,
40
+ editRoster,
41
+ levelInfo,
42
+ normalizeConfig,
43
+ pruneOrphans,
44
+ setGroupLevel,
45
+ setLevel,
46
+ } from './service.js';
47
+ import { collectTargets } from './toolgate.js';
48
+ import { DEFAULT_LEVEL } from './store.js';
49
+
50
+ export const name = 'dsh-isolation-gate';
51
+ /** `tools` 是硬依赖:工具执行门没有它就不成立。 */
52
+ export const inject = ['tools'];
53
+
54
+ const VERSION = '0.2.0';
55
+ const BASE = '/__dsh-isolation-gate';
56
+ const MAX_BODY = 256 * 1024;
57
+
58
+ /** 轻量同源栅栏:本 API 只给本机 DSH 窗口用。 */
59
+ function isLocalRequest(req) {
60
+ const origin = req?.headers?.origin;
61
+ if (typeof origin !== 'string' || origin === '' || origin === 'null') return true;
62
+ try {
63
+ const url = new URL(origin);
64
+ return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function readJsonBody(req) {
71
+ return new Promise((resolve, reject) => {
72
+ const chunks = [];
73
+ let size = 0;
74
+ req.on('data', (chunk) => {
75
+ size += chunk.length;
76
+ if (size > MAX_BODY) {
77
+ reject(new Error('请求体过大'));
78
+ req.destroy();
79
+ return;
80
+ }
81
+ chunks.push(chunk);
82
+ });
83
+ req.on('end', () => {
84
+ const text = Buffer.concat(chunks).toString('utf8');
85
+ if (text.trim() === '') return resolve({});
86
+ try {
87
+ resolve(JSON.parse(text));
88
+ } catch (error) {
89
+ reject(new Error(`请求体不是合法 JSON:${error?.message ?? String(error)}`));
90
+ }
91
+ });
92
+ req.on('error', reject);
93
+ });
94
+ }
95
+
96
+ export function apply(ctx, config) {
97
+ const cfg = normalizeConfig(config);
98
+ console.log(`[dsh-isolation-gate] host half loaded (v${VERSION})`);
99
+ console.log(`[dsh-isolation-gate] 状态文件:${cfg.statePath}`);
100
+ console.log(`[dsh-isolation-gate] 名单:${cfg.rosterPath}`);
101
+
102
+ // ---- 工具执行门 -------------------------------------------------------
103
+ // 每次工具调用都重新读盘(与 GUI 一样,不缓存)⇒ 面板一改,下一次工具调用即生效。
104
+ const toolGateDecision = (exec) => {
105
+ const collected = collectTargets(exec?.name, exec?.arguments);
106
+ let firstDeny = null;
107
+ let evaluated = 0;
108
+ for (const target of collected.targets) {
109
+ const decision = checkTool(cfg, { path: target.path, op: target.op });
110
+ evaluated += 1;
111
+ if (decision.ok && decision.allowed === false && firstDeny === null) {
112
+ firstDeny = decision;
113
+ break;
114
+ }
115
+ if (decision.ok && decision.inRoster === false) continue;
116
+ }
117
+ if (firstDeny !== null) {
118
+ return (
119
+ `门禁:拒绝工具「${collected.tool}」对「${firstDeny.matchedKey ?? firstDeny.name ?? firstDeny.target}」` +
120
+ `的「${firstDeny.opLabel ?? firstDeny.op}」。${firstDeny.reason}`
121
+ );
122
+ }
123
+ return undefined;
124
+ };
125
+
126
+ if (cfg.toolGate !== false) {
127
+ ctx.effect(() =>
128
+ ctx.tools.guard((exec) => {
129
+ try {
130
+ return toolGateDecision(exec);
131
+ } catch (error) {
132
+ // 判定自身出错 ⇒ fail-closed(宁可拒绝,不可放行)。
133
+ return `门禁:工具门判定异常,按拒绝处理 —— ${error?.message ?? String(error)}`;
134
+ }
135
+ }),
136
+ );
137
+ console.log('[dsh-isolation-gate] 工具执行门已挂载(ctx.tools.guard · fail-closed)');
138
+ } else {
139
+ console.log('[dsh-isolation-gate] 工具执行门已关闭(config.toolGate=false,仅通道层生效)');
140
+ }
141
+
142
+ // ---- 目录选择器(可选能力:优先宿主抽象服务,Windows 上自带原生兜底)--------
143
+ // 契约:capability() 返回 `native`(pick)或 `browse`(list/createDirectory)之一。
144
+ const pickerCapability = (() => {
145
+ try {
146
+ const service = ctx.get('directoryPicker');
147
+ return service ? service.capability() : undefined;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ })();
152
+ const hostNativePicker =
153
+ pickerCapability && pickerCapability.kind === 'native' && typeof pickerCapability.pick === 'function'
154
+ ? pickerCapability
155
+ : null;
156
+ // 本机(Windows)自带的系统文件夹对话框:宿主未装载选择器实现时用它,效果与系统「选择文件夹」一致。
157
+ const osNativePicker = process.platform === 'win32';
158
+ const pickerReady = Boolean(hostNativePicker) || osNativePicker;
159
+ const pickerKind = hostNativePicker ? 'native' : osNativePicker ? 'native-os' : null;
160
+ if (!pickerReady) {
161
+ console.log('[dsh-isolation-gate] 目录选择器不可用 ⇒ 面板使用"未入名单目录 + 文本框"退路');
162
+ }
163
+
164
+ function pickWithWindowsDialog() {
165
+ return new Promise((resolve, reject) => {
166
+ const script = [
167
+ 'Add-Type -AssemblyName System.Windows.Forms;',
168
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog;',
169
+ "$d.Description = '选择要加入门禁的目录';",
170
+ '$d.ShowNewFolderButton = $true;',
171
+ "$d.SelectedPath = 'D:\\';",
172
+ 'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }',
173
+ ].join(' ');
174
+ const child = spawn('powershell.exe', ['-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-Command', script], {
175
+ windowsHide: true,
176
+ });
177
+ let out = '';
178
+ child.stdout.on('data', (chunk) => {
179
+ out += chunk.toString('utf8');
180
+ });
181
+ child.on('error', reject);
182
+ child.on('close', () => resolve(out.trim() || null));
183
+ });
184
+ }
185
+
186
+ const send = (res, status, payload) => {
187
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
188
+ res.end(JSON.stringify(payload));
189
+ };
190
+
191
+ const report = (auditLimit = 10) => buildReport(cfg, { auditLimit, picker: pickerReady, pickerKind });
192
+
193
+ const routes = [
194
+ {
195
+ method: 'GET',
196
+ path: `${BASE}/ping`,
197
+ handler: async () => ({
198
+ ok: true,
199
+ name,
200
+ version: VERSION,
201
+ routes: [
202
+ `GET ${BASE}/ping`,
203
+ `GET ${BASE}/state`,
204
+ `POST ${BASE}/level`,
205
+ `POST ${BASE}/roster`,
206
+ `POST ${BASE}/group-level`,
207
+ `POST ${BASE}/group`,
208
+ `POST ${BASE}/prune`,
209
+ `POST ${BASE}/pick`,
210
+ `POST ${BASE}/toolcheck`,
211
+ `POST ${BASE}/check`,
212
+ `GET ${BASE}/log`,
213
+ ],
214
+ statePath: cfg.statePath,
215
+ rosterPath: cfg.rosterPath,
216
+ root: cfg.root,
217
+ guardChannel: 'dsh-isolation-gate/bin/gate.mjs',
218
+ toolGate: cfg.toolGate !== false,
219
+ picker: pickerReady,
220
+ pickerKind,
221
+ ...levelInfo(),
222
+ }),
223
+ },
224
+ {
225
+ method: 'GET',
226
+ path: `${BASE}/state`,
227
+ handler: async (_req, _res, url) => {
228
+ const limit = Number.parseInt(url.searchParams.get('auditLimit') ?? '10', 10);
229
+ return report(Number.isInteger(limit) && limit > 0 ? Math.min(limit, 200) : 10);
230
+ },
231
+ },
232
+ {
233
+ method: 'POST',
234
+ path: `${BASE}/level`,
235
+ handler: async (req) => {
236
+ const body = await readJsonBody(req);
237
+ const result = setLevel(cfg, { name: body.name, level: body.level, note: body.note, by: 'gui' });
238
+ if (!result.ok) return result;
239
+ console.log(`[dsh-isolation-gate] ${result.name} → ${result.level}(${result.levelLabel})`);
240
+ return { ...result, report: report() };
241
+ },
242
+ },
243
+ {
244
+ method: 'POST',
245
+ path: `${BASE}/roster`,
246
+ handler: async (req) => {
247
+ const body = await readJsonBody(req);
248
+ const result = editRoster(cfg, { action: body.action, name: body.name });
249
+ if (!result.ok) return result;
250
+ console.log(
251
+ `[dsh-isolation-gate] 名单${result.action === 'add' ? '加入' : '移除'} ${result.name}` +
252
+ `(备份:${result.backup ?? '无'})`,
253
+ );
254
+ return { ...result, report: report() };
255
+ },
256
+ },
257
+ {
258
+ method: 'POST',
259
+ path: `${BASE}/group-level`,
260
+ handler: async (req) => {
261
+ const body = await readJsonBody(req);
262
+ const result = setGroupLevel(cfg, { group: body.group, level: body.level, by: 'gui' });
263
+ if (!result.ok) return result;
264
+ console.log(`[dsh-isolation-gate] 组「${result.group}」→ ${result.level}(${result.count} 个对象)`);
265
+ return { ...result, report: report() };
266
+ },
267
+ },
268
+ {
269
+ method: 'POST',
270
+ path: `${BASE}/group`,
271
+ handler: async (req) => {
272
+ const body = await readJsonBody(req);
273
+ const action = body.action;
274
+ const result =
275
+ action === 'create'
276
+ ? createGroup(cfg, { group: body.group, note: body.note })
277
+ : action === 'assign'
278
+ ? assignGroup(cfg, { name: body.name, group: body.group })
279
+ : action === 'delete'
280
+ ? deleteGroup(cfg, { group: body.group })
281
+ : { ok: false, error: `action 只接受 create / assign / delete,收到:${String(action)}` };
282
+ if (!result.ok) return result;
283
+ if (action === 'create') console.log(`[dsh-isolation-gate] 新建分组「${result.group}」(空组)`);
284
+ return { ...result, report: report() };
285
+ },
286
+ },
287
+ {
288
+ method: 'POST',
289
+ path: `${BASE}/prune`,
290
+ handler: async () => {
291
+ const result = pruneOrphans(cfg);
292
+ if (!result.ok) return result;
293
+ console.log(`[dsh-isolation-gate] 清理孤儿键 ${result.removed.length} 个`);
294
+ return { ...result, report: report() };
295
+ },
296
+ },
297
+ {
298
+ method: 'POST',
299
+ path: `${BASE}/pick`,
300
+ handler: async () => {
301
+ if (!pickerReady) {
302
+ return { ok: false, error: '当前宿主没有可用的目录选择器(请用下方"未入名单目录"或手动输入路径)' };
303
+ }
304
+ try {
305
+ const picked = hostNativePicker
306
+ ? await hostNativePicker.pick(new AbortController().signal)
307
+ : await pickWithWindowsDialog();
308
+ if (typeof picked !== 'string' || picked.trim() === '') {
309
+ return { ok: false, canceled: true, error: '已取消选择' };
310
+ }
311
+ return { ok: true, path: picked };
312
+ } catch (error) {
313
+ return { ok: false, error: `目录选择器调用失败:${error?.message ?? String(error)}` };
314
+ }
315
+ },
316
+ },
317
+ {
318
+ method: 'POST',
319
+ path: `${BASE}/toolcheck`,
320
+ handler: async (req) => {
321
+ const body = await readJsonBody(req);
322
+ const collected = collectTargets(body.tool, body.args ?? {});
323
+ const decisions = collected.targets.map((target) => {
324
+ const decision = check(cfg, {
325
+ path: target.path,
326
+ op: target.op,
327
+ channel: 'toolcheck',
328
+ });
329
+ return {
330
+ op: target.op,
331
+ path: target.path,
332
+ via: target.via,
333
+ allowed: decision.allowed,
334
+ inRoster: decision.inRoster,
335
+ matchedKey: decision.matchedKey,
336
+ level: decision.level,
337
+ reason: decision.reason,
338
+ error: decision.error,
339
+ };
340
+ });
341
+ const denied = decisions.filter((item) => item.allowed === false);
342
+ return {
343
+ ok: true,
344
+ tool: collected.tool,
345
+ shell: collected.shell,
346
+ unknownTool: collected.unknownTool,
347
+ targets: decisions,
348
+ wouldDeny: denied.length > 0,
349
+ reason: denied[0]?.reason ?? null,
350
+ defaultLevel: DEFAULT_LEVEL,
351
+ };
352
+ },
353
+ },
354
+ {
355
+ method: 'POST',
356
+ path: `${BASE}/check`,
357
+ handler: async (req) => {
358
+ const body = await readJsonBody(req);
359
+ return check(cfg, { name: body.name, path: body.path, op: body.op, channel: 'http' });
360
+ },
361
+ },
362
+ {
363
+ method: 'GET',
364
+ path: `${BASE}/log`,
365
+ handler: async (_req, _res, url) => {
366
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '20', 10);
367
+ const snapshot = report(Number.isInteger(limit) && limit > 0 ? Math.min(limit, 200) : 20);
368
+ return { ok: true, audit: snapshot.audit, paths: snapshot.paths };
369
+ },
370
+ },
371
+ ];
372
+
373
+ const register = (host, targetCtx) => {
374
+ for (const route of routes) {
375
+ targetCtx.effect(() =>
376
+ host.register({
377
+ kind: 'exact',
378
+ path: route.path,
379
+ handler: async (req, res) => {
380
+ let url;
381
+ try {
382
+ url = new URL(req.url ?? route.path, 'http://127.0.0.1');
383
+ } catch {
384
+ send(res, 400, { ok: false, error: '无法解析请求 URL' });
385
+ return;
386
+ }
387
+ if (!isLocalRequest(req)) {
388
+ send(res, 403, { ok: false, error: '只接受本机窗口的请求' });
389
+ return;
390
+ }
391
+ if ((req.method ?? 'GET').toUpperCase() !== route.method) {
392
+ send(res, 405, { ok: false, error: `method not allowed (use ${route.method})` });
393
+ return;
394
+ }
395
+ try {
396
+ const payload = await route.handler(req, res, url);
397
+ const failed = payload !== null && typeof payload === 'object' && payload.ok === false;
398
+ send(res, failed ? 400 : 200, payload);
399
+ } catch (error) {
400
+ send(res, 400, { ok: false, error: error?.message ?? String(error) });
401
+ }
402
+ },
403
+ }),
404
+ );
405
+ }
406
+ };
407
+
408
+ const webServer = ctx.get('webServer');
409
+ if (webServer !== undefined) {
410
+ register(webServer, ctx);
411
+ } else {
412
+ // 没有 web 面的 profile:等它,而不是挂死。
413
+ ctx.inject(['webServer'], (sub) => {
414
+ register(sub.webServer, sub);
415
+ });
416
+ }
417
+ }