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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * 门禁 · 工具执行门(`ctx.tools.guard` 的判定层)
3
+ *
4
+ * 目的:把"受管对象三级"从**通道层**推进到**工具执行层** —— 模型调用
5
+ * `read / write / edit / grep / glob / bash / pwsh …` 时,在工具真正执行前判定。
6
+ *
7
+ * 判定仍是 `guard.checkAccess` 一套(三级 × 四操作),本文件只负责两件事:
8
+ * ① 从工具名 + 参数里**认出**要碰哪些路径、各是读 / 写 / 删;
9
+ * ② 把一次工具调用拆成若干 `{ op, path }` 目标(可多目标)。
10
+ *
11
+ * 诚实边界(README 必须同步写):
12
+ * · 只覆盖**宿主工具调用**;raw fs / 独立进程 / 其他程序直接读写**拦不住**。
13
+ * · shell 是**文本启发式**:`cd` + 相对路径、命令替换、变量间接、编码、符号链接
14
+ * 都可能绕过(与同类实现同一层级的已知限制)。
15
+ * · 认不出的命令**不静默放行**:只要命令文本里出现受管绝对路径,就按"写"判定
16
+ * (只在 full 级放行)—— 宁可误拒,不可漏放。
17
+ *
18
+ * 纯函数、无 I/O —— 自测可直接调。
19
+ */
20
+
21
+ import { win32 } from 'node:path';
22
+
23
+ /** 参数里可能装路径的键(任意嵌套深度都会看,深度上限 8)。 */
24
+ export const PATH_KEYS = new Set([
25
+ 'path',
26
+ 'paths',
27
+ 'file',
28
+ 'files',
29
+ 'file_path',
30
+ 'filePath',
31
+ 'filename',
32
+ 'dir',
33
+ 'dirs',
34
+ 'directory',
35
+ 'directories',
36
+ 'cwd',
37
+ 'workspace',
38
+ 'root',
39
+ 'target',
40
+ 'targets',
41
+ 'output',
42
+ 'output_path',
43
+ 'outputPath',
44
+ 'destination',
45
+ 'dest',
46
+ 'source',
47
+ 'src',
48
+ ]);
49
+
50
+ /** 工具名 → 操作(认不出的工具返回 null)。 */
51
+ export const TOOL_OP = {
52
+ read: 'read',
53
+ read_image: 'read',
54
+ read_file: 'read',
55
+ list: 'traverse',
56
+ ls: 'traverse',
57
+ view: 'read',
58
+ open: 'read',
59
+ stat: 'read',
60
+ head: 'read',
61
+ tail: 'read',
62
+ cat: 'read',
63
+ grep: 'read',
64
+ glob: 'read',
65
+ find: 'read',
66
+ search: 'read',
67
+ write: 'write',
68
+ write_file: 'write',
69
+ edit: 'write',
70
+ apply_patch: 'write',
71
+ create: 'write',
72
+ create_file: 'write',
73
+ mkdir: 'write',
74
+ move: 'write',
75
+ copy: 'write',
76
+ rename: 'write',
77
+ delete: 'delete',
78
+ remove: 'delete',
79
+ rmdir: 'delete',
80
+ unlink: 'delete',
81
+ };
82
+
83
+ /** 命令词 → 操作。 */
84
+ const CMD_OP = {
85
+ // 读
86
+ cat: 'read',
87
+ type: 'read',
88
+ more: 'read',
89
+ less: 'read',
90
+ head: 'read',
91
+ tail: 'read',
92
+ dir: 'read',
93
+ ls: 'read',
94
+ 'get-childitem': 'read',
95
+ 'get-content': 'read',
96
+ gc: 'read',
97
+ findstr: 'read',
98
+ 'select-string': 'read',
99
+ where: 'read',
100
+ 'get-item': 'read',
101
+ stat: 'read',
102
+ file: 'read',
103
+ du: 'read',
104
+ tree: 'read',
105
+ cd: 'traverse',
106
+ pushd: 'traverse',
107
+ popd: 'traverse',
108
+ pwd: 'traverse',
109
+ echo: 'read',
110
+ 'write-output': 'read',
111
+ // 写
112
+ cp: 'write',
113
+ copy: 'write',
114
+ 'copy-item': 'write',
115
+ mv: 'write',
116
+ move: 'write',
117
+ 'move-item': 'write',
118
+ ren: 'write',
119
+ rename: 'write',
120
+ 'rename-item': 'write',
121
+ mkdir: 'write',
122
+ md: 'write',
123
+ 'new-item': 'write',
124
+ ni: 'write',
125
+ touch: 'write',
126
+ 'set-content': 'write',
127
+ sc: 'write',
128
+ 'out-file': 'write',
129
+ tee: 'write',
130
+ 'tee-object': 'write',
131
+ 'add-content': 'write',
132
+ ac: 'write',
133
+ robocopy: 'write',
134
+ xcopy: 'write',
135
+ tar: 'write',
136
+ unzip: 'write',
137
+ 'expand-archive': 'write',
138
+ sed: 'write',
139
+ perl: 'write',
140
+ // 删
141
+ rm: 'delete',
142
+ del: 'delete',
143
+ erase: 'delete',
144
+ rmdir: 'delete',
145
+ rd: 'delete',
146
+ 'remove-item': 'delete',
147
+ ri: 'delete',
148
+ unlink: 'delete',
149
+ 'clear-content': 'delete',
150
+ clc: 'delete',
151
+ shred: 'delete',
152
+ format: 'delete',
153
+ };
154
+
155
+ /** 管道解释器(`curl x | sh` 的右侧):把它当执行,按未知命令处理(偏严)。 */
156
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'pwsh', 'powershell', 'cmd', 'node', 'python', 'python3', 'pwsh7']);
157
+
158
+ /** 我们的受管辖通道:由它自己判定,工具门不重复扫(否则 `gate.mjs read --path …` 会被误杀)。 */
159
+ export function isOwnChannelCommand(command) {
160
+ if (typeof command !== 'string') return false;
161
+ if (!/gate\.mjs/i.test(command)) return false;
162
+ return /\b(status|policy|check|traverse|read|write|delete|rmdir|log)\b/i.test(command) || /--help|-h\b/i.test(command);
163
+ }
164
+
165
+ /** 绝对路径判定(Windows 盘符 / UNC)。 */
166
+ export function isAbsolutePathToken(token) {
167
+ if (typeof token !== 'string') return false;
168
+ const value = token.trim().replace(/^['"`]|['"`]$/g, '');
169
+ return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]/.test(value);
170
+ }
171
+
172
+ function stripQuotes(value) {
173
+ return String(value).replace(/^['"`]|['"`]$/g, '');
174
+ }
175
+
176
+ /** 把 `--path=D:\x` / `-Path:D:\x` 拆成 `D:\x`。 */
177
+ function pathFromToken(token) {
178
+ const cleaned = stripQuotes(token);
179
+ const eq = cleaned.indexOf('=');
180
+ if (eq > 0) {
181
+ const right = cleaned.slice(eq + 1);
182
+ if (isAbsolutePathToken(right)) return right;
183
+ }
184
+ const colon = cleaned.indexOf(':');
185
+ if (colon > 1 && isAbsolutePathToken(cleaned.slice(colon + 1))) return cleaned.slice(colon + 1);
186
+ return isAbsolutePathToken(cleaned) ? cleaned : null;
187
+ }
188
+
189
+ /** 简单分词:按空白切,尊重单 / 双引号。 */
190
+ export function tokenize(command) {
191
+ const tokens = [];
192
+ let current = '';
193
+ let quote = null;
194
+ for (let i = 0; i < command.length; i += 1) {
195
+ const ch = command[i];
196
+ if (quote) {
197
+ if (ch === quote) quote = null;
198
+ else current += ch;
199
+ continue;
200
+ }
201
+ if (ch === '"' || ch === "'") {
202
+ quote = ch;
203
+ continue;
204
+ }
205
+ if (/\s/.test(ch)) {
206
+ if (current !== '') {
207
+ tokens.push(current);
208
+ current = '';
209
+ }
210
+ continue;
211
+ }
212
+ current += ch;
213
+ }
214
+ if (current !== '') tokens.push(current);
215
+ return tokens;
216
+ }
217
+
218
+ /** 按控制符拆段(保留 `|` 的段界;每段独立判定)。 */
219
+ export function splitSegments(command) {
220
+ return String(command)
221
+ .split(/\r?\n|&&|\|\||;|\|/)
222
+ .map((part) => part.trim())
223
+ .filter((part) => part !== '');
224
+ }
225
+
226
+ function resolveMaybeRelative(token, cwd) {
227
+ const cleaned = stripQuotes(token);
228
+ if (isAbsolutePathToken(cleaned)) return cleaned;
229
+ if (cwd && /^[^<>|&]+$/.test(cleaned) && !/^-/.test(cleaned) && /[\\/]/.test(cleaned)) {
230
+ return win32.resolve(cwd, cleaned);
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * 从一条 shell 命令里认出 `{op, path}` 目标。
237
+ * @param {string} command
238
+ * @returns {Array<{op:string,path:string,via:string}>}
239
+ */
240
+ export function classifyShell(command) {
241
+ if (typeof command !== 'string' || command.trim() === '') return [];
242
+ if (isOwnChannelCommand(command)) return [];
243
+
244
+ const targets = [];
245
+ let cwd = null;
246
+ for (const segment of splitSegments(command)) {
247
+ const tokens = tokenize(segment);
248
+ if (tokens.length === 0) continue;
249
+
250
+ // 重定向目标 = 写
251
+ for (let i = 0; i < tokens.length; i += 1) {
252
+ if (/^\d?>>?$/.test(tokens[i])) {
253
+ const next = tokens[i + 1];
254
+ const path = next ? pathFromToken(next) ?? resolveMaybeRelative(next, cwd) : null;
255
+ if (path) targets.push({ op: 'write', path, via: 'redirect' });
256
+ }
257
+ }
258
+
259
+ let index = 0;
260
+ // 跳过 `sudo` / `&` 之类前缀
261
+ while (index < tokens.length && /^[&]$|^sudo$/i.test(tokens[index])) index += 1;
262
+ const rawCmd = tokens[index] ?? '';
263
+ const cmd = stripQuotes(rawCmd).toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/, '');
264
+ if (cmd === '') continue;
265
+
266
+ // `bash -c "..."` / `pwsh -Command "..."`:递归看里面的命令
267
+ if (SHELLS.has(cmd)) {
268
+ const cIndex = tokens.findIndex((token) => /^-(c|command)$/i.test(token));
269
+ if (cIndex >= 0 && tokens[cIndex + 1]) {
270
+ targets.push(...classifyShell(tokens.slice(cIndex + 1).join(' ')));
271
+ continue;
272
+ }
273
+ }
274
+
275
+ let op = CMD_OP[cmd] ?? null;
276
+ const pathTokens = tokens.slice(index + 1).map(pathFromToken).filter(Boolean);
277
+ const relativePaths = tokens
278
+ .slice(index + 1)
279
+ .map((token) => resolveMaybeRelative(token, cwd))
280
+ .filter(Boolean);
281
+
282
+ if (cmd === 'cd' || cmd === 'pushd') {
283
+ const next = pathTokens[0] ?? relativePaths[0] ?? null;
284
+ if (next) {
285
+ cwd = isAbsolutePathToken(next) ? next : win32.resolve(cwd ?? '.', next);
286
+ targets.push({ op: 'traverse', path: cwd, via: 'cd' });
287
+ }
288
+ continue;
289
+ }
290
+
291
+ if (cmd === 'echo' && targets.some((t) => t.via === 'redirect')) continue;
292
+
293
+ if (op === null) op = 'write'; // 未知命令 + 受管路径 ⇒ 按写处理(偏严)
294
+
295
+ for (const path of [...pathTokens, ...relativePaths]) {
296
+ targets.push({ op, path, via: `cmd:${cmd}` });
297
+ }
298
+ }
299
+ return targets;
300
+ }
301
+
302
+ /** 递归收集参数里的路径(按 PATH_KEYS)。 */
303
+ export function collectArgumentPaths(value, depth = 0, key = null, out = []) {
304
+ if (depth > 8 || value === null || value === undefined) return out;
305
+ if (typeof value === 'string') {
306
+ if (key !== null && PATH_KEYS.has(key) && value.trim() !== '') out.push(value.trim());
307
+ return out;
308
+ }
309
+ if (Array.isArray(value)) {
310
+ for (const item of value) collectArgumentPaths(item, depth + 1, key, out);
311
+ return out;
312
+ }
313
+ if (typeof value === 'object') {
314
+ for (const [childKey, child] of Object.entries(value)) collectArgumentPaths(child, depth + 1, childKey, out);
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * 一次工具调用的完整判定输入。
321
+ * @returns {{tool:string, targets:Array<{op:string,path:string,via:string}>, shell:boolean, unknownTool:boolean}}
322
+ */
323
+ export function collectTargets(toolName, args) {
324
+ const tool = typeof toolName === 'string' ? toolName : '';
325
+ const shellNames = new Set(['bash', 'pwsh', 'shell', 'terminal', 'run_command', 'exec', 'execute']);
326
+ const shell = shellNames.has(tool);
327
+ const targets = [];
328
+
329
+ if (shell) {
330
+ const command =
331
+ args && typeof args === 'object'
332
+ ? args.command ?? args.cmd ?? args.script ?? args.command_line ?? args.commandLine ?? ''
333
+ : '';
334
+ targets.push(...classifyShell(typeof command === 'string' ? command : ''));
335
+ // shell 工具的参数里也可能直接给了 cwd
336
+ for (const path of collectArgumentPaths(args).filter((p) => !targets.some((t) => t.path === p))) {
337
+ targets.push({ op: 'traverse', path, via: 'arg' });
338
+ }
339
+ return { tool, targets: dedupe(targets), shell, unknownTool: false };
340
+ }
341
+
342
+ let op = TOOL_OP[tool] ?? null;
343
+ // str_replace_editor 的 command 决定读还是写
344
+ if (tool === 'str_replace_editor' && args && typeof args === 'object') {
345
+ const command = String(args.command ?? '').toLowerCase();
346
+ op = command === 'view' ? 'read' : 'write';
347
+ }
348
+ if (op === null) {
349
+ // 未登记的工具:只要参数里出现受管路径,就按写处理(偏严,避免漏放)
350
+ const paths = collectArgumentPaths(args);
351
+ return {
352
+ tool,
353
+ targets: dedupe(paths.map((path) => ({ op: 'write', path, via: 'unknown-tool' }))),
354
+ shell,
355
+ unknownTool: true,
356
+ };
357
+ }
358
+ for (const path of collectArgumentPaths(args)) targets.push({ op, path, via: 'arg' });
359
+ return { tool, targets: dedupe(targets), shell, unknownTool: false };
360
+ }
361
+
362
+ function dedupe(targets) {
363
+ const seen = new Set();
364
+ const out = [];
365
+ for (const target of targets) {
366
+ const id = `${target.op}|${String(target.path).toLowerCase()}`;
367
+ if (seen.has(id)) continue;
368
+ seen.add(id);
369
+ out.push(target);
370
+ }
371
+ return out;
372
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Access Gate",
4
+ "description": "Access gate for directories: adds a '门禁' entry right below 'Skills' in the app-level nav row (official sidebar.panellist seat, order 6), diffs the managed roster (D:\\.agents\\门禁-名单.txt — one managed object per line: a top-level directory name under D:\\ or any absolute path) against the disk, toggles each object between three levels (deny / read / full), and persists the state to D:\\.agents\\门禁-授权.json for agents and tools to read. Paths match by longest path-segment prefix; a missing or corrupt roster makes the gate deny everything (fail-closed). Roster edits only land after the owner presses the button in the panel, and the file is backed up first."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "门禁 · 目录访问管制",
4
+ "description": "门禁 · 目录访问管制:在导航排「技能」正下方加一个「门禁」入口(官方 sidebar.panellist 槽位,order 6),把受管名单(D:\\.agents\\门禁-名单.txt —— 一行一个受管对象,D 根一级目录【名字】或【任意绝对路径】)与磁盘比对并标出差异,逐个受管对象切换三级授权(禁 / 只读 / 可读写删),状态持久化到 D:\\.agents\\门禁-授权.json 供 agent / 工具侧读取;路径按「路径段最长前缀」命中,名单读不到时一律拒绝;名单增删只在界面里给出差异、由户主点击后落盘(改前先备份到回收站)。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "dsh-isolation-gate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "门禁 · 目录访问管制(隔离区授权台):一屏管理「哪些目录受管、各自什么级别」,三级=禁/只读/可读写删;判定同时挂在受管辖通道(bin/gate.mjs)与宿主工具执行门(ctx.tools.guard)上,默认禁、名单缺失一律拒绝(fail-closed)、审计可查、agent 可自查;支持分组批量切、目录选择器与状态文件自动迁移。",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "dsh-bundle",
9
+ "isolation-gate",
10
+ "permissions",
11
+ "isolation",
12
+ "authorization",
13
+ "sandbox",
14
+ "agent-safety",
15
+ "access-control",
16
+ "windows"
17
+ ],
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "type": "module",
23
+ "main": "./lib/index.js",
24
+ "exports": {
25
+ ".": {
26
+ "default": "./lib/index.js"
27
+ },
28
+ "./client": {
29
+ "default": "./lib/client.js"
30
+ },
31
+ "./package.json": "./package.json",
32
+ "./locale/*.json": "./locale/*.json"
33
+ },
34
+ "bin": {
35
+ "gate": "./bin/gate.mjs"
36
+ },
37
+ "files": [
38
+ "lib",
39
+ "bin",
40
+ "locale",
41
+ "cordis.patch.yml",
42
+ "README.md",
43
+ "README.en.md",
44
+ "CHANGELOG.md",
45
+ "LICENSE",
46
+ "docs"
47
+ ],
48
+ "engines": {
49
+ "node": ">=20.11"
50
+ },
51
+ "scripts": {
52
+ "test": "node test/run-all.mjs",
53
+ "gate": "node bin/gate.mjs"
54
+ },
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "inject": [
61
+ "@deepseek-ai/dsh-client-runtime"
62
+ ],
63
+ "platform": "web"
64
+ }
65
+ },
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/zhaorui-wu/dsh-isolation-gate.git"
69
+ },
70
+ "homepage": "https://github.com/zhaorui-wu/dsh-isolation-gate#readme",
71
+ "bugs": {
72
+ "url": "https://github.com/zhaorui-wu/dsh-isolation-gate/issues"
73
+ }
74
+ }