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/bin/gate.mjs ADDED
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate —— 受管辖的访问通道(agent / 工具侧唯一入口)
4
+ *
5
+ * 用法(全部先判定、后动手;判定不通过就**一个字节都不碰**):
6
+ *
7
+ * node bin/gate.mjs status 看全部受管对象的级别(agent 读状态用)
8
+ * node bin/gate.mjs policy 紧凑策略行(可直接塞进提示词/规则)
9
+ * node bin/gate.mjs check --name Zcode --op read
10
+ * node bin/gate.mjs check --path "D:\\Zcode\\某文件" --op delete
11
+ * node bin/gate.mjs check --path "E:\\资料\\合作档案\\某项目\\a.md" --op read
12
+ * node bin/gate.mjs traverse --path "D:\\Zcode"
13
+ * node bin/gate.mjs read --path "D:\\Zcode\\某文件"
14
+ * node bin/gate.mjs write --path "D:\\Zcode\\某文件" --text "内容"
15
+ * node bin/gate.mjs delete --path "D:\\Zcode\\某文件" (删前自动备份到回收站)
16
+ * node bin/gate.mjs rmdir --path "D:\\Zcode\\空目录" (只删**空目录**;不递归,非空一律拒)
17
+ * node bin/gate.mjs log --limit 20
18
+ *
19
+ * 退出码:0 = 放行 / 成功 · 3 = **被拒**(授权不足,拒绝文案在 stderr) · 2 = 用错或环境错误。
20
+ *
21
+ * 可选参数:--state <路径> · --roster <路径> · --root <盘根> · --log <审计路径>
22
+ * --channel <通道名> · --json · --no-backup
23
+ *
24
+ * ⚠️ **fail-closed**:名单文件缺失 / 读坏 ⇒ **一律拒绝**(exit 3,文案含"名单不可用")。
25
+ * 旧版在同样情形下会"放行一切"(隔离悄悄失效)—— 现已改为拒到底。
26
+ *
27
+ * ⚠️ 本通道**只**能替代"你本来要直接读写受管对象"的那一步;它不会自己变聪明:
28
+ * 走别的路绕开它,等于把"授权以 GUI 门禁开关为准"这条规则丢掉。
29
+ */
30
+
31
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
32
+ import { dirname, join, relative, resolve } from 'node:path';
33
+
34
+ import { appendAudit, readAudit } from '../lib/audit.js';
35
+ import { buildReport, check, levelInfo, normalizeConfig } from '../lib/service.js';
36
+ import { readRoster } from '../lib/roster.js';
37
+ import { readState } from '../lib/store.js';
38
+
39
+ const CHANNEL = 'dsh-isolation-gate/bin/gate.mjs';
40
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
41
+ const DEFAULT_DELETE_BACKUP_DIR = 'D:\\回收站\\门禁-改前';
42
+
43
+ function parseArgs(argv) {
44
+ const positional = [];
45
+ const options = {};
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const token = argv[i];
48
+ if (token.startsWith('--')) {
49
+ const eq = token.indexOf('=');
50
+ if (eq > 0) {
51
+ options[token.slice(2, eq)] = token.slice(eq + 1);
52
+ continue;
53
+ }
54
+ const key = token.slice(2);
55
+ const next = argv[i + 1];
56
+ if (next === undefined || next.startsWith('--')) {
57
+ options[key] = true;
58
+ continue;
59
+ }
60
+ options[key] = next;
61
+ i += 1;
62
+ continue;
63
+ }
64
+ positional.push(token);
65
+ }
66
+ return { positional, options };
67
+ }
68
+
69
+ function configFrom(options) {
70
+ const raw = {};
71
+ if (typeof options.state === 'string') raw.statePath = options.state;
72
+ if (typeof options.roster === 'string') raw.rosterPath = options.roster;
73
+ if (typeof options.root === 'string') raw.root = options.root;
74
+ if (typeof options.log === 'string') raw.auditPath = options.log;
75
+ if (typeof options['backup-dir'] === 'string') raw.backupDir = options['backup-dir'];
76
+ return normalizeConfig(raw);
77
+ }
78
+
79
+ function emit(options, payload, human) {
80
+ if (options.json === true) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
81
+ else process.stdout.write(`${human}\n`);
82
+ }
83
+
84
+ function deny(options, decision) {
85
+ const message = decision.error ? `拒绝/出错:${decision.error}` : decision.reason;
86
+ if (options.json === true) {
87
+ process.stdout.write(`${JSON.stringify({ ok: true, allowed: false, decision }, null, 2)}\n`);
88
+ } else {
89
+ process.stderr.write(`${message}\n`);
90
+ }
91
+ process.exit(decision.error ? 2 : 3);
92
+ }
93
+
94
+ /**
95
+ * 判定 → 通过才继续;不通过就退出(不碰任何文件)。
96
+ * 名单读不到时判定侧**一律拒绝**(fail-closed),所以这里不需要再补丁。
97
+ */
98
+ function gate(cfg, options, target) {
99
+ const stateRead = readState(cfg.statePath);
100
+ const rosterRead = readRoster(cfg.rosterPath);
101
+ const decision = check(cfg, {
102
+ name: target.name,
103
+ path: target.path,
104
+ op: target.op,
105
+ channel: typeof options.channel === 'string' && options.channel !== '' ? options.channel : CHANNEL,
106
+ });
107
+ if (!decision.ok || !decision.allowed) {
108
+ deny(options, decision);
109
+ return decision; // deny() 已经 exit,这一行只为静态可读性
110
+ }
111
+ if (!stateRead.ok && !rosterRead.ok) {
112
+ // 配置都读不出来还放行是不可能的(放行只会来自"不属于任何受管对象")—— 不做特殊处理。
113
+ }
114
+ return decision;
115
+ }
116
+
117
+ function statusCommand(cfg, options) {
118
+ const report = buildReport(cfg, { auditLimit: 5 });
119
+ const lines = [];
120
+ lines.push(`门禁状态(唯一生效依据:${report.paths.state})`);
121
+ lines.push(`受管名单:${report.paths.roster}(存在=${report.files.rosterExists})`);
122
+ if (report.files.gateClosed) lines.push(`⚠ ${report.files.rosterGateMessage}`);
123
+ if (report.files.stateError) lines.push(`⚠ 状态文件:${report.files.stateError}`);
124
+ if ((report.files.rosterInvalid || []).length > 0) {
125
+ lines.push(`⚠ 名单里有不合法、未生效的行:${report.files.rosterInvalid.map((row) => row.raw).join(' / ')}`);
126
+ }
127
+ lines.push('');
128
+ if (report.roster.length === 0) lines.push('(名单为空)');
129
+ for (const row of report.roster) {
130
+ lines.push(
131
+ ` ${(row.isPath ? '[路径] ' : '')}${row.name.padEnd(14)} = ${row.level.padEnd(5)}(${row.levelLabel})` +
132
+ ` · 允许:${row.allowedOps.join(',') || '无'}` +
133
+ (row.dirExists ? '' : ' ⚠ 目录不存在'),
134
+ );
135
+ }
136
+ if (report.stateOnly.length > 0) {
137
+ lines.push('');
138
+ lines.push('(状态里有、名单里没有 ⇒ 当前不受管辖)');
139
+ for (const row of report.stateOnly) lines.push(` ${row.name} = ${row.level}(${row.levelLabel})`);
140
+ }
141
+ if (options.json === true) {
142
+ emit(options, { ok: true, state: report.paths.state, roster: report.roster, stateOnly: report.stateOnly }, '');
143
+ return 0;
144
+ }
145
+ process.stdout.write(`${lines.join('\n')}\n`);
146
+ return 0;
147
+ }
148
+
149
+ function policyCommand(cfg, options) {
150
+ const report = buildReport(cfg, { auditLimit: 1 });
151
+ const text = report.policy.join('\n');
152
+ if (options.json === true) emit(options, { ok: true, policy: report.policy, levels: levelInfo() }, '');
153
+ else process.stdout.write(`${text}\n`);
154
+ return 0;
155
+ }
156
+
157
+ function checkCommand(cfg, options) {
158
+ const op = typeof options.op === 'string' ? options.op : 'read';
159
+ const decision = check(cfg, {
160
+ name: typeof options.name === 'string' ? options.name : undefined,
161
+ path: typeof options.path === 'string' ? options.path : undefined,
162
+ op,
163
+ channel: typeof options.channel === 'string' ? options.channel : CHANNEL,
164
+ });
165
+ if (!decision.ok) {
166
+ process.stderr.write(`判定失败:${decision.error}\n`);
167
+ return 2;
168
+ }
169
+ if (options.json === true) process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
170
+ else {
171
+ const levelText = decision.rosterUnavailable === true ? '名单不可用' : (decision.level ?? '不在名单');
172
+ process.stdout.write(
173
+ `${decision.allowed ? '放行' : '拒绝'} · ${decision.name ?? '?'} · 级别=${levelText} · 操作=${op}\n` +
174
+ `${decision.reason}\n`,
175
+ );
176
+ }
177
+ return decision.allowed ? 0 : 3;
178
+ }
179
+
180
+ function traverseCommand(cfg, options) {
181
+ const target = resolve(String(options.path));
182
+ const decision = gate(cfg, options, { path: target, op: 'traverse' });
183
+ let entries;
184
+ try {
185
+ entries = readdirSync(target, { withFileTypes: true }).map((entry) => ({
186
+ name: entry.name,
187
+ kind: entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'link' : 'file',
188
+ }));
189
+ } catch (error) {
190
+ process.stderr.write(`列出失败:${error?.message ?? String(error)}\n`);
191
+ return 2;
192
+ }
193
+ entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
194
+ if (options.json === true) emit(options, { ok: true, allowed: true, path: target, decision, entries }, '');
195
+ else {
196
+ process.stdout.write(`放行:${target}(${entries.length} 项)\n`);
197
+ // 放行理由也要说清:是"没被隔离管辖"还是"已授权"——两者含义完全不同。
198
+ if (decision.inRoster === false) process.stdout.write(` · ${decision.reason}\n`);
199
+ for (const entry of entries) process.stdout.write(` [${entry.kind}] ${entry.name}\n`);
200
+ }
201
+ return 0;
202
+ }
203
+
204
+ function readCommand(cfg, options) {
205
+ const target = resolve(String(options.path));
206
+ gate(cfg, options, { path: target, op: 'read' });
207
+ let stats;
208
+ try {
209
+ stats = statSync(target);
210
+ } catch (error) {
211
+ process.stderr.write(`读取失败:${error?.message ?? String(error)}\n`);
212
+ return 2;
213
+ }
214
+ if (!stats.isFile()) {
215
+ process.stderr.write(`读取失败:不是普通文件(${target})\n`);
216
+ return 2;
217
+ }
218
+ if (stats.size > MAX_READ_BYTES) {
219
+ process.stderr.write(`读取失败:文件 ${stats.size} 字节,超过通道上限 ${MAX_READ_BYTES}\n`);
220
+ return 2;
221
+ }
222
+ let text;
223
+ try {
224
+ text = readFileSync(target, 'utf8');
225
+ } catch (error) {
226
+ process.stderr.write(`读取失败:${error?.message ?? String(error)}\n`);
227
+ return 2;
228
+ }
229
+ if (options.json === true) emit(options, { ok: true, allowed: true, path: target, bytes: stats.size, text }, '');
230
+ else process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
231
+ return 0;
232
+ }
233
+
234
+ function writeCommand(cfg, options) {
235
+ const target = resolve(String(options.path));
236
+ gate(cfg, options, { path: target, op: 'write' });
237
+ if (typeof options.text !== 'string') {
238
+ process.stderr.write('写入失败:缺少 --text(本通道不做交互式编辑)\n');
239
+ return 2;
240
+ }
241
+ try {
242
+ mkdirSync(dirname(target), { recursive: true });
243
+ writeFileSync(target, options.text, 'utf8');
244
+ } catch (error) {
245
+ process.stderr.write(`写入失败:${error?.message ?? String(error)}\n`);
246
+ return 2;
247
+ }
248
+ appendAudit(cfg.auditPath, {
249
+ channel: CHANNEL,
250
+ name: null,
251
+ op: 'write',
252
+ allowed: true,
253
+ detail: `已写 ${target}(${Buffer.byteLength(options.text, 'utf8')} 字节)`,
254
+ });
255
+ if (options.json === true) emit(options, { ok: true, allowed: true, path: target, bytes: Buffer.byteLength(options.text, 'utf8') }, '');
256
+ else process.stdout.write(`已写入:${target}\n`);
257
+ return 0;
258
+ }
259
+
260
+ function deleteCommand(cfg, options) {
261
+ const target = resolve(String(options.path));
262
+ gate(cfg, options, { path: target, op: 'delete' });
263
+ if (!existsSync(target)) {
264
+ process.stderr.write(`删除失败:目标不存在(${target})\n`);
265
+ return 2;
266
+ }
267
+ let stats;
268
+ try {
269
+ stats = statSync(target);
270
+ } catch (error) {
271
+ process.stderr.write(`删除失败:${error?.message ?? String(error)}\n`);
272
+ return 2;
273
+ }
274
+ if (stats.isDirectory()) {
275
+ process.stderr.write(
276
+ '删除失败:本通道只删**文件**,不删目录(目录的递归删/搬属高风险动作,' +
277
+ '请按房规先备份、由户主/管家单独授权执行;空目录请用 rmdir)\n',
278
+ );
279
+ return 2;
280
+ }
281
+ let backup = null;
282
+ if (options['no-backup'] !== true) {
283
+ try {
284
+ const backupDir = join(String(options['backup-dir'] ?? DEFAULT_DELETE_BACKUP_DIR));
285
+ mkdirSync(backupDir, { recursive: true });
286
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
287
+ backup = join(backupDir, `${stamp}__${target.replace(/[:\\/]/g, '_')}`);
288
+ cpSync(target, backup);
289
+ } catch (error) {
290
+ process.stderr.write(`删除中止:删前备份失败(房规:删前必先备份)—— ${error?.message ?? String(error)}\n`);
291
+ return 2;
292
+ }
293
+ }
294
+ try {
295
+ rmSync(target, { force: true });
296
+ } catch (error) {
297
+ process.stderr.write(`删除失败:${error?.message ?? String(error)}\n`);
298
+ return 2;
299
+ }
300
+ appendAudit(cfg.auditPath, {
301
+ channel: CHANNEL,
302
+ name: null,
303
+ op: 'delete',
304
+ allowed: true,
305
+ detail: `已删 ${target};备份=${backup ?? '(--no-backup 跳过)'}`,
306
+ });
307
+ if (options.json === true) emit(options, { ok: true, allowed: true, path: target, backup }, '');
308
+ else process.stdout.write(`已删除:${target}(备份:${backup ?? '未做'})\n`);
309
+ return 0;
310
+ }
311
+
312
+ /**
313
+ * 删**空目录**(不递归)。
314
+ *
315
+ * 为什么补这个命令(2026-10-09 户主实测发现):`write` 会自动建父目录 ⇒ 通道**能建目录**,
316
+ * 却没有能力删掉自己建的目录 ⇒ 收尾必留残渣。这里只补"删空目录"这一最小能力;
317
+ * 非空目录**一律拒绝**(递归删仍属高风险,按房规走户主/管家单独授权)。
318
+ * 护栏:① 只删受管范围之下第 2 层及更深(盘根与受管对象本身不可由本通道删)
319
+ * ② 只删空目录(不递归)③ 目标是链接/junction 时拒(本通道不动链接)
320
+ */
321
+ function rmdirCommand(cfg, options) {
322
+ const target = resolve(String(options.path));
323
+ gate(cfg, options, { path: target, op: 'delete' });
324
+ if (!existsSync(target)) {
325
+ process.stderr.write(`删除目录失败:目标不存在(${target})\n`);
326
+ return 2;
327
+ }
328
+ let link;
329
+ try {
330
+ link = lstatSync(target);
331
+ } catch (error) {
332
+ process.stderr.write(`删除目录失败:${error?.message ?? String(error)}\n`);
333
+ return 2;
334
+ }
335
+ if (link.isSymbolicLink()) {
336
+ process.stderr.write(`删除目录失败:目标是链接 / junction(${target})—— 本通道不动链接,请人工处理\n`);
337
+ return 2;
338
+ }
339
+ if (!link.isDirectory()) {
340
+ process.stderr.write(`删除目录失败:不是目录(${target})—— 删文件请用 delete\n`);
341
+ return 2;
342
+ }
343
+ const rel = relative(resolve(cfg.root), target);
344
+ const depth = rel.split(/[\\/]/).filter(Boolean).length;
345
+ if (depth < 2) {
346
+ process.stderr.write(
347
+ `删除目录失败:只能删「受管范围之下第 2 层及更深」的目录;${target} 是第 ${depth} 层` +
348
+ '(盘根与受管对象目录本身不得由本通道删除)\n',
349
+ );
350
+ return 2;
351
+ }
352
+ const entries = readdirSync(target);
353
+ if (entries.length > 0) {
354
+ process.stderr.write(
355
+ `删除目录失败:目录非空(${entries.length} 项)—— 本通道**不递归删**;` +
356
+ '请先用 delete 逐个删文件,或由户主 / 管家按房规单独授权处理\n',
357
+ );
358
+ return 2;
359
+ }
360
+ try {
361
+ // ⚠️ 必须用 rmdirSync:fs.rmSync(recursive:false) 对**目录**会直接抛 EISDIR(2026-10-09 实测踩到)。
362
+ // rmdirSync 本身只删空目录 —— 与"不递归"的语义天然一致。
363
+ rmdirSync(target);
364
+ } catch (error) {
365
+ process.stderr.write(`删除目录失败:${error?.message ?? String(error)}\n`);
366
+ return 2;
367
+ }
368
+ appendAudit(cfg.auditPath, {
369
+ channel: CHANNEL,
370
+ name: null,
371
+ op: 'delete',
372
+ allowed: true,
373
+ detail: `已删空目录 ${target}(空目录无内容可备份)`,
374
+ });
375
+ if (options.json === true) emit(options, { ok: true, allowed: true, path: target, removed: 'empty-dir' }, '');
376
+ else process.stdout.write(`已删除空目录:${target}(空目录无内容,未做备份)\n`);
377
+ return 0;
378
+ }
379
+
380
+ function logCommand(cfg, options) {
381
+ const limit = Number.parseInt(String(options.limit ?? '20'), 10);
382
+ const result = readAudit(cfg.auditPath, Number.isInteger(limit) && limit > 0 ? limit : 20);
383
+ if (options.json === true) emit(options, { ok: true, audit: result }, '');
384
+ else if (result.entries.length === 0) process.stdout.write(`(无审计记录;${cfg.auditPath})\n`);
385
+ else {
386
+ process.stdout.write(`审计(最新在前,共 ${result.total ?? 0} 条;${cfg.auditPath})\n`);
387
+ for (const entry of result.entries) {
388
+ process.stdout.write(
389
+ ` ${entry.atLocal ?? entry.at} · ${entry.allowed ? '放行' : '拒绝'} · ${entry.channel} · ` +
390
+ `${entry.name ?? '-'} · ${entry.op} · ${entry.detail ?? ''}\n`,
391
+ );
392
+ }
393
+ }
394
+ return 0;
395
+ }
396
+
397
+ function usage() {
398
+ process.stdout.write(
399
+ [
400
+ 'gate —— 受管辖的访问通道',
401
+ '',
402
+ ' status 看全部受管对象的级别(agent 读状态用)',
403
+ ' policy 紧凑策略行(塞提示词用)',
404
+ ' check --name X --op read 只判定不动手',
405
+ ' check --path P --op delete 路径也支持(最长前缀命中)',
406
+ ' traverse --path P 列目录名(需 traverse 权限)',
407
+ ' read --path P 读文件(需 read 权限)',
408
+ ' write --path P --text S 写文件(需 write 权限)',
409
+ ' delete --path P 删**文件**(需 delete 权限;删前自动备份)',
410
+ ' rmdir --path P 删**空目录**(需 delete 权限;不递归,非空则拒)',
411
+ ' log [--limit 20] 审计日志',
412
+ '',
413
+ ' 级别:deny 禁(默认) / read 只读 / full 可读写删',
414
+ ' 退出码:0 放行 · 3 被拒 · 2 用错或环境错误',
415
+ ' fail-closed:名单缺失 / 损坏 ⇒ 一律拒绝(exit 3,文案含"名单不可用")',
416
+ '',
417
+ ].join('\n'),
418
+ );
419
+ return 2;
420
+ }
421
+
422
+ function main() {
423
+ const { positional, options } = parseArgs(process.argv.slice(2));
424
+ const command = positional[0];
425
+ if (!command || command === 'help' || options.help === true) return usage();
426
+ const cfg = configFrom(options);
427
+ switch (command) {
428
+ case 'status':
429
+ return statusCommand(cfg, options);
430
+ case 'policy':
431
+ return policyCommand(cfg, options);
432
+ case 'check':
433
+ return checkCommand(cfg, options);
434
+ case 'traverse':
435
+ case 'list':
436
+ if (typeof options.path !== 'string') {
437
+ process.stderr.write('traverse 需要 --path\n');
438
+ return 2;
439
+ }
440
+ return traverseCommand(cfg, options);
441
+ case 'read':
442
+ if (typeof options.path !== 'string') {
443
+ process.stderr.write('read 需要 --path\n');
444
+ return 2;
445
+ }
446
+ return readCommand(cfg, options);
447
+ case 'write':
448
+ if (typeof options.path !== 'string') {
449
+ process.stderr.write('write 需要 --path\n');
450
+ return 2;
451
+ }
452
+ return writeCommand(cfg, options);
453
+ case 'delete':
454
+ if (typeof options.path !== 'string') {
455
+ process.stderr.write('delete 需要 --path\n');
456
+ return 2;
457
+ }
458
+ return deleteCommand(cfg, options);
459
+ case 'rmdir':
460
+ if (typeof options.path !== 'string') {
461
+ process.stderr.write('rmdir 需要 --path\n');
462
+ return 2;
463
+ }
464
+ return rmdirCommand(cfg, options);
465
+ case 'log':
466
+ return logCommand(cfg, options);
467
+ default:
468
+ process.stderr.write(`未知命令:${command}\n`);
469
+ return usage();
470
+ }
471
+ }
472
+
473
+ process.exitCode = main();
@@ -0,0 +1,37 @@
1
+ # dsh-isolation-gate bundle patch
2
+ #
3
+ # 挂上本插件的 loader 行。两个半边:
4
+ #
5
+ # · 宿主半(lib/index.js)—— 本机 HTTP 路由 /__dsh-isolation-gate/*:
6
+ # GET /__dsh-isolation-gate/ping 插件身份 + 三级说明
7
+ # GET /__dsh-isolation-gate/state 名单 / 级别 / 磁盘差异 / 审计尾部 / agent 策略行
8
+ # POST /__dsh-isolation-gate/level 改一个受管对象的三级授权(写状态文件)
9
+ # POST /__dsh-isolation-gate/roster 名单增删(**先备份**,新增项自动建 deny)
10
+ # POST /__dsh-isolation-gate/check 受管辖通道判定(不读任何内容)
11
+ # GET /__dsh-isolation-gate/log 最近审计
12
+ # 它只读写两个配置文件(状态 JSON / 名单 TXT)、列出盘根**一层目录名**,
13
+ # 并对名单里**显式列出的路径**做一次存在性判定;受管对象内部内容的读写全部在
14
+ # bin/gate.mjs 那条受管辖通道里,同样由本插件判定。
15
+ #
16
+ # · 客户端半(lib/client.js,经 package.json 的 dsh.client 声明)—— 通过框架官方
17
+ # `slots` 服务挂两个座位(与官方「插件」「自动化任务」页同一个扩展点):
18
+ # 1. 左上应用级导航排 `sidebar.panellist`(root 作用域 **list**)→ 图标行,
19
+ # `order: 6` —— 官方「插件」= 0、「自动化任务」= 10、已装的「技能」= 5,
20
+ # 因此 6 正好落在**「技能」正下方**(外壳按 order 升序排,与加载先后无关)。
21
+ # 2. 主区 `main`(root 作用域 **keyed**)→ 整页,key 同行 id:`isolation-gate`。
22
+ # 因此本包**不声明任何 @deepseek-ai/dsh\* peer 依赖**(它从不 import 它们;
23
+ # `slots` 是经 ctx.inject 来的运行时服务)⇒ 不受 DSH 兼容性豁免闸门影响。
24
+ #
25
+ # 装配方式(**归管家域**,本卡不动 profile)——二者取一:
26
+ # (a) 在 profile 的 dsh.profile.bundles 列表里加 "dsh-isolation-gate";或
27
+ # (b) 用用户 patch 层插同一行:
28
+ #
29
+ # - insert:
30
+ # - id: isolation-gate
31
+ # name: 'dsh-isolation-gate'
32
+ #
33
+ # `name` 就是 npm 包名;loader 从 profile 的 node_modules 里解析它。
34
+
35
+ - insert:
36
+ - id: isolation-gate
37
+ name: 'dsh-isolation-gate'
@@ -0,0 +1,36 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="320" viewBox="0 0 1200 320" role="img" aria-label="门禁 · dsh-isolation-gate">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#0b1220"/>
5
+ <stop offset="0.55" stop-color="#101a2e"/>
6
+ <stop offset="1" stop-color="#1a1030"/>
7
+ </linearGradient>
8
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
9
+ <stop offset="0" stop-color="#5eead4"/>
10
+ <stop offset="0.5" stop-color="#60a5fa"/>
11
+ <stop offset="1" stop-color="#c084fc"/>
12
+ </linearGradient>
13
+ </defs>
14
+ <rect width="1200" height="320" fill="url(#bg)"/>
15
+ <g opacity="0.18" stroke="#94a3b8" stroke-width="1">
16
+ <path d="M0 80h1200M0 160h1200M0 240h1200"/>
17
+ <path d="M200 0v320M400 0v320M600 0v320M800 0v320M1000 0v320"/>
18
+ </g>
19
+ <g transform="translate(80,92)">
20
+ <rect x="0" y="0" width="96" height="96" rx="22" fill="#0f172a" stroke="url(#accent)" stroke-width="3"/>
21
+ <path d="M30 52V40a18 18 0 0 1 36 0v12" fill="none" stroke="#5eead4" stroke-width="6" stroke-linecap="round"/>
22
+ <rect x="24" y="52" width="48" height="30" rx="7" fill="#5eead4"/>
23
+ <circle cx="48" cy="66" r="4" fill="#0b1220"/>
24
+ </g>
25
+ <text x="208" y="132" font-family="Segoe UI, PingFang SC, Microsoft YaHei, sans-serif" font-size="46" font-weight="700" fill="#f8fafc">门禁 · dsh-isolation-gate</text>
26
+ <text x="208" y="176" font-family="Segoe UI, PingFang SC, Microsoft YaHei, sans-serif" font-size="22" fill="#94a3b8">默认禁 · 名单制 · 分组批量 · 可审计 · agent 自查</text>
27
+ <g font-family="Segoe UI, Microsoft YaHei, sans-serif" font-size="17">
28
+ <rect x="208" y="204" width="120" height="36" rx="18" fill="#0f172a" stroke="#334155"/>
29
+ <text x="268" y="228" text-anchor="middle" fill="#e2e8f0">deny</text>
30
+ <rect x="340" y="204" width="120" height="36" rx="18" fill="#0f172a" stroke="#334155"/>
31
+ <text x="400" y="228" text-anchor="middle" fill="#e2e8f0">read</text>
32
+ <rect x="472" y="204" width="120" height="36" rx="18" fill="#0f172a" stroke="#334155"/>
33
+ <text x="532" y="228" text-anchor="middle" fill="#e2e8f0">full</text>
34
+ </g>
35
+ <text x="1120" y="286" text-anchor="end" font-family="Segoe UI, Microsoft YaHei, sans-serif" font-size="16" fill="#64748b">for DeepSeek Harness</text>
36
+ </svg>
Binary file
@@ -0,0 +1,9 @@
1
+ # 截图目录
2
+
3
+ | 文件 | 内容 | 来源 |
4
+ |---|---|---|
5
+ | `01-overview.png` | 新界面总览:摘要条(受管 3 / 分组 0 / 工具门已开 / 名字 2·路径 1)+ v1 自动迁移提示 + 三个受管对象 + 分段控件 + 未入名单目录 | 户主真机截图(2026-10-21 19:0x,重启后) |
6
+ | `02-group.png` | 「harness 隔离」组卡片:2 个成员、组头分段控件、组内统一「禁」 | **待补**(需在门禁页刷新后截图;户主已确认界面有此组) |
7
+ | `03-picker-fallback.png` | 目录选择器不可用时的退路:未入名单一层目录 + 路径输入 | **待补**(当前宿主 `picker=false`) |
8
+
9
+ > 截图只用于文档;不含任何受管目录内部内容。
package/lib/audit.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 门禁 · 访问审计(受管辖通道的每一次判定都留痕)
3
+ *
4
+ * 落点:`~/.dsh/dsh-isolation-gate/access-log.jsonl`(插件数据位,非源码位)。
5
+ * 记什么:时间 · 通道 · 名字 · 级别 · 操作 · 放行/拒绝。
6
+ * **不记**隔离区内部内容 —— 只记路径名与判定结果(红线:只碰名字与存在性)。
7
+ *
8
+ * 有上限(默认 500 行):超了从头截掉,避免日志无限长。
9
+ */
10
+
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ /** 插件数据目录(房规:插件数据进 `~\.dsh\<插件名>\`)。 */
16
+ export const DEFAULT_LOG_DIR = join(homedir(), '.dsh', 'dsh-isolation-gate');
17
+
18
+ /** 审计日志默认落点。 */
19
+ export const DEFAULT_LOG_PATH = join(DEFAULT_LOG_DIR, 'access-log.jsonl');
20
+
21
+ /** 保留的最大行数。 */
22
+ export const DEFAULT_MAX_ENTRIES = 500;
23
+
24
+ function pad(n) {
25
+ return String(n).padStart(2, '0');
26
+ }
27
+
28
+ function stamp(now) {
29
+ const d = now instanceof Date ? now : new Date();
30
+ return (
31
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
32
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
33
+ );
34
+ }
35
+
36
+ /** 读日志(逆序,最新在前)。永不抛异常。 */
37
+ export function readAudit(logPath = DEFAULT_LOG_PATH, limit = 20) {
38
+ if (!existsSync(logPath)) return { ok: true, exists: false, entries: [], error: null };
39
+ let text;
40
+ try {
41
+ text = readFileSync(logPath, 'utf8');
42
+ } catch (error) {
43
+ return { ok: false, exists: true, entries: [], error: `审计日志读取失败:${error?.message ?? String(error)}` };
44
+ }
45
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
46
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
47
+ const entries = [];
48
+ for (let i = lines.length - 1; i >= 0 && entries.length < Math.max(1, limit); i -= 1) {
49
+ try {
50
+ entries.push(JSON.parse(lines[i]));
51
+ } catch {
52
+ entries.push({ raw: lines[i], parseError: true });
53
+ }
54
+ }
55
+ return { ok: true, exists: true, total: lines.length, entries, error: null };
56
+ }
57
+
58
+ /**
59
+ * 追加一条审计。失败只报错、不抛 —— 审计不该拦住主流程。
60
+ * @param {string} logPath
61
+ * @param {{channel:string,name?:string,level?:string,op:string,allowed:boolean,detail?:string}} entry
62
+ */
63
+ export function appendAudit(logPath, entry, options = {}) {
64
+ const maxEntries = Number.isInteger(options.maxEntries) && options.maxEntries > 0 ? options.maxEntries : DEFAULT_MAX_ENTRIES;
65
+ const record = {
66
+ at: options.now instanceof Date ? options.now.toISOString() : new Date().toISOString(),
67
+ atLocal: stamp(options.now),
68
+ channel: entry?.channel ?? 'unknown',
69
+ name: entry?.name ?? null,
70
+ level: entry?.level ?? null,
71
+ op: entry?.op ?? null,
72
+ allowed: entry?.allowed === true,
73
+ detail: typeof entry?.detail === 'string' ? entry.detail.slice(0, 300) : undefined,
74
+ };
75
+ try {
76
+ mkdirSync(dirname(logPath), { recursive: true });
77
+ appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8');
78
+ } catch (error) {
79
+ return { ok: false, error: `审计写入失败:${error?.message ?? String(error)}` };
80
+ }
81
+ // 超上限就该截断(低频动作,读一次整文件可以接受)。
82
+ try {
83
+ const text = readFileSync(logPath, 'utf8');
84
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
85
+ if (lines.length > maxEntries) {
86
+ writeFileSync(logPath, `${lines.slice(lines.length - maxEntries).join('\n')}\n`, 'utf8');
87
+ }
88
+ } catch {
89
+ /* 截断失败无所谓:宁可日志长一点,也不能因此丢记录或报错 */
90
+ }
91
+ return { ok: true, record };
92
+ }