dsh-isolation-gate 0.2.3 → 0.3.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.
package/lib/index.js CHANGED
@@ -44,14 +44,16 @@ import {
44
44
  setGroupLevel,
45
45
  setLevel,
46
46
  } from './service.js';
47
+ import { clearAudit } from './audit.js';
47
48
  import { collectTargets } from './toolgate.js';
49
+ import { decideWithProfiles, knownDirBindings, loadProfileState } from './profiles-runtime.js';
48
50
  import { DEFAULT_LEVEL } from './store.js';
49
51
 
50
52
  export const name = 'dsh-isolation-gate';
51
53
  /** `tools` 是硬依赖:工具执行门没有它就不成立。 */
52
54
  export const inject = ['tools'];
53
55
 
54
- const VERSION = '0.2.0';
56
+ const VERSION = '0.3.0';
55
57
  const BASE = '/__dsh-isolation-gate';
56
58
  const MAX_BODY = 256 * 1024;
57
59
 
@@ -103,6 +105,21 @@ export function apply(ctx, config) {
103
105
  // 每次工具调用都重新读盘(与 GUI 一样,不缓存)⇒ 面板一改,下一次工具调用即生效。
104
106
  const toolGateDecision = (exec) => {
105
107
  const collected = collectTargets(exec?.name, exec?.arguments);
108
+
109
+ // ① 策略档(会话覆盖 > 目录 .dsh-gate.json > 默认档)。blocklist 档在这里直接放行。
110
+ const profileDecision = decideWithProfiles(cfg.statePath, {
111
+ sessionId: exec?.agent?.id,
112
+ tool: collected.tool,
113
+ targets: collected.targets,
114
+ shell: collected.shell,
115
+ command: typeof exec?.arguments?.command === 'string' ? exec.arguments.command : undefined,
116
+ known: collected.unknownTool !== true,
117
+ });
118
+ if (!profileDecision.allowed) {
119
+ const where = profileDecision.profile ? `档「${profileDecision.profile}」` : '策略档';
120
+ return `门禁:${where}拒绝工具「${collected.tool}」${profileDecision.matched ? `(${profileDecision.matched})` : ''}。${profileDecision.reason}`;
121
+ }
122
+
106
123
  let firstDeny = null;
107
124
  let evaluated = 0;
108
125
  for (const target of collected.targets) {
@@ -201,6 +218,7 @@ export function apply(ctx, config) {
201
218
  routes: [
202
219
  `GET ${BASE}/ping`,
203
220
  `GET ${BASE}/state`,
221
+ `GET ${BASE}/profiles`,
204
222
  `POST ${BASE}/level`,
205
223
  `POST ${BASE}/roster`,
206
224
  `POST ${BASE}/group-level`,
@@ -210,6 +228,7 @@ export function apply(ctx, config) {
210
228
  `POST ${BASE}/toolcheck`,
211
229
  `POST ${BASE}/check`,
212
230
  `GET ${BASE}/log`,
231
+ `POST ${BASE}/audit/clear`,
213
232
  ],
214
233
  statePath: cfg.statePath,
215
234
  rosterPath: cfg.rosterPath,
@@ -221,6 +240,22 @@ export function apply(ctx, config) {
221
240
  ...levelInfo(),
222
241
  }),
223
242
  },
243
+ {
244
+ method: 'GET',
245
+ path: `${BASE}/profiles`,
246
+ handler: async () => {
247
+ const state = loadProfileState(cfg.statePath)
248
+ const profiles = [...state.profiles.values()].map((profile) => ({
249
+ name: profile.name,
250
+ mode: profile.mode,
251
+ allow: profile.allow,
252
+ network: profile.network,
253
+ unknownTool: profile.unknownTool,
254
+ isDefault: profile.name === state.defaultProfile,
255
+ }))
256
+ return { ok: true, defaultProfile: state.defaultProfile, profiles, dirBindings: knownDirBindings() }
257
+ },
258
+ },
224
259
  {
225
260
  method: 'GET',
226
261
  path: `${BASE}/state`,
@@ -359,6 +394,16 @@ export function apply(ctx, config) {
359
394
  return check(cfg, { name: body.name, path: body.path, op: body.op, channel: 'http' });
360
395
  },
361
396
  },
397
+ {
398
+ method: 'POST',
399
+ path: `${BASE}/audit/clear`,
400
+ handler: async () => {
401
+ const result = clearAudit(cfg.auditPath)
402
+ if (!result.ok) return result
403
+ console.log(`[dsh-isolation-gate] 审计已清空 ${result.removed} 条(备份:${result.backup})`)
404
+ return { ...result, report: report() }
405
+ },
406
+ },
362
407
  {
363
408
  method: 'GET',
364
409
  path: `${BASE}/log`,
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 门禁 · 策略档运行时(有 I/O,但只在工具门前同步跑)
3
+ *
4
+ * 职责:
5
+ * ① 从状态文件读档表(带 mtime 缓存;解析失败 ⇒ 只剩 sealed 档,fail-closed)
6
+ * ② 从目标路径向上找 `.dsh-gate.json`(带 mtime 缓存;坏文件 ⇒ 按 sealed 处理)
7
+ * ③ 合并成"这次调用用哪个档",再交给纯函数 evaluateCall 判定
8
+ *
9
+ * 语义:`.dsh-gate.json` 管的是**它所在的这棵子树**,不是某个会话 —— 谁碰这棵树都按这档。
10
+ * 会话覆盖(state.sessionOverrides[sessionId])优先,用于一次性严苛任务。
11
+ */
12
+
13
+ import { existsSync, readFileSync, statSync } from 'node:fs';
14
+ import { win32 } from 'node:path';
15
+ import { BUILTIN_PROFILES, evaluateCall, narrowProfile, normalizeProfile, readProfiles } from './profiles.js';
16
+
17
+ const BINDING_FILE = '.dsh-gate.json';
18
+ const MAX_DEPTH = 8;
19
+
20
+ /** @type {Map<string, {mtimeMs:number, value:any}>} */
21
+ const stateCache = new Map();
22
+ /** @type {Map<string, {mtimeMs:number, value:any}>} */
23
+ const bindingCache = new Map();
24
+
25
+ function statMtime(filePath) {
26
+ try {
27
+ return statSync(filePath).mtimeMs;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** 读状态文件里的档配置;文件不存在 ⇒ 内置档(normal/strict/sealed)。 */
34
+ export function loadProfileState(statePath) {
35
+ if (typeof statePath !== 'string' || statePath.trim() === '') {
36
+ return { profiles: builtinMap(), defaultProfile: 'normal', sessionOverrides: {} };
37
+ }
38
+ const mtime = statMtime(statePath);
39
+ if (mtime === null) {
40
+ return { profiles: builtinMap(), defaultProfile: 'normal', sessionOverrides: {} };
41
+ }
42
+ const hit = stateCache.get(statePath);
43
+ if (hit && hit.mtimeMs === mtime) return hit.value;
44
+
45
+ let value;
46
+ try {
47
+ const raw = JSON.parse(readFileSync(statePath, 'utf8'));
48
+ value = readProfiles(raw);
49
+ // 状态里没写 profiles ⇒ 补上内置模板档,方便直接引用 strict / sealed
50
+ for (const [name, def] of Object.entries(BUILTIN_PROFILES)) {
51
+ if (!value.profiles.has(name)) value.profiles.set(name, normalizeProfile(name, def));
52
+ }
53
+ } catch (error) {
54
+ // 状态文件坏了:只留一个全拒档 —— 宁可停摆,不可放行。
55
+ const sealed = new Map([['sealed', normalizeProfile('sealed', { mode: 'sealed' })]]);
56
+ value = { profiles: sealed, defaultProfile: 'sealed', sessionOverrides: {}, error: error?.message };
57
+ }
58
+ stateCache.set(statePath, { mtimeMs: mtime, value });
59
+ return value;
60
+ }
61
+
62
+ function builtinMap() {
63
+ const map = new Map();
64
+ for (const [name, def] of Object.entries(BUILTIN_PROFILES)) map.set(name, normalizeProfile(name, def));
65
+ return map;
66
+ }
67
+
68
+ /**
69
+ * 从目标路径向上找最近的 `.dsh-gate.json`。
70
+ * @returns {{dir:string, profile?:string, allow?:any[], network?:string, unknownTool?:string, broken?:boolean}|null}
71
+ */
72
+ export function findDirBinding(targetPath) {
73
+ if (typeof targetPath !== 'string' || !win32.isAbsolute(targetPath)) return null;
74
+ let dir = win32.dirname(win32.normalize(targetPath));
75
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
76
+ const filePath = win32.join(dir, BINDING_FILE);
77
+ if (existsSync(filePath)) {
78
+ const mtime = statMtime(filePath);
79
+ const hit = bindingCache.get(filePath);
80
+ if (hit && hit.mtimeMs === mtime) return { ...hit.value, dir };
81
+ let value;
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
84
+ value =
85
+ parsed && typeof parsed === 'object' && !Array.isArray(parsed)
86
+ ? { profile: typeof parsed.profile === 'string' ? parsed.profile : undefined, ...parsed }
87
+ : { broken: true };
88
+ } catch {
89
+ value = { broken: true };
90
+ }
91
+ bindingCache.set(filePath, { mtimeMs: mtime, value });
92
+ return { ...value, dir };
93
+ }
94
+ const parent = win32.dirname(dir);
95
+ if (parent === dir) return null;
96
+ dir = parent;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * 一次工具调用的完整判定(含档解析)。
103
+ * @param {string} statePath
104
+ * @param {{sessionId?:string, tool:string, targets:Array<{op:string,path:string}>, shell?:boolean, command?:string, known?:boolean}} call
105
+ * @returns {{allowed:boolean, reason:string, profile?:string, source?:string, matched?:string}}
106
+ */
107
+ export function decideWithProfiles(statePath, call) {
108
+ const state = loadProfileState(statePath);
109
+ const sid = typeof call.sessionId === 'string' ? call.sessionId : '';
110
+ const override = sid && state.sessionOverrides[sid];
111
+
112
+ // 会话覆盖:整次调用都按这一档(含联网与未知工具判定)
113
+ if (override && state.profiles.has(override)) {
114
+ const profile = state.profiles.get(override);
115
+ const result = evaluateCall(profile, call);
116
+ return { ...result, profile: profile.name, source: 'session' };
117
+ }
118
+
119
+ // 目录绑定:逐个目标各自找配置;任一目标被拒即拒。
120
+ // 没有目标(如 web_search)时只按默认档判 —— 网络工具在 sealed 默认档下会被拦。
121
+ const targets = Array.isArray(call.targets) ? call.targets : [];
122
+ let source = 'default';
123
+ let usedProfile = state.profiles.get(state.defaultProfile);
124
+
125
+ for (const target of targets) {
126
+ const binding = findDirBinding(target.path);
127
+ if (!binding) continue;
128
+ if (binding.broken) {
129
+ return {
130
+ allowed: false,
131
+ reason: `${BINDING_FILE} 无法解析,按 sealed 处理`,
132
+ profile: 'sealed',
133
+ source: 'dir',
134
+ matched: target.path,
135
+ };
136
+ }
137
+ const baseName = binding.profile && state.profiles.has(binding.profile) ? binding.profile : state.defaultProfile;
138
+ const base = state.profiles.get(baseName);
139
+ const profile = narrowProfile(base, binding);
140
+ const result = evaluateCall(profile, { ...call, targets: [target] });
141
+ source = 'dir';
142
+ usedProfile = profile;
143
+ if (!result.allowed) return { ...result, profile: profile.name, source };
144
+ }
145
+
146
+ // 没有目录配置的目标:退回默认档
147
+ const result = evaluateCall(usedProfile, call);
148
+ return { ...result, profile: usedProfile.name, source };
149
+ }
150
+
151
+ /** 本进程见过的目录绑定(面板展示用;不是全盘扫描)。 */
152
+ export function knownDirBindings() {
153
+ return [...bindingCache.entries()].map(([file, hit]) => ({
154
+ dir: win32.dirname(file),
155
+ profile: (hit.value && hit.value.profile) || '',
156
+ file,
157
+ }))
158
+ }
159
+
160
+ /** 测试用:清缓存 */
161
+ export function __clearProfileCaches() {
162
+ stateCache.clear();
163
+ bindingCache.clear();
164
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * 门禁 · 策略档(profile)
3
+ *
4
+ * 一个「档」= 一套判定规则:
5
+ * - blocklist:名单里的对象按三级管,其余不管(旧行为)
6
+ * - allowlist:只允许 allow 里列出的路径与级别,其余一律拒
7
+ * - sealed :allowlist + 禁网 + 未知工具一律拒
8
+ *
9
+ * 会话与档的关系:会话不定义规则,只**挂**一个档。
10
+ * 解析优先级:会话覆盖(sessionId)> 会话目录绑定(.dsh-gate.json)> 全局默认档。
11
+ *
12
+ * 纯函数、无 I/O —— 自测可直接调。
13
+ */
14
+
15
+ import { win32 } from 'node:path';
16
+
17
+ export const MODES = ['blocklist', 'allowlist', 'sealed'];
18
+ export const LEVEL_RANK = { deny: 0, read: 1, full: 2 };
19
+
20
+ /** 工具名 → 是否属于"联网类"。按名字判,不看参数。 */
21
+ export const NETWORK_TOOLS = new Set([
22
+ 'web_search',
23
+ 'web_fetch',
24
+ 'web_download',
25
+ 'web_open',
26
+ 'browser',
27
+ 'browser_navigate',
28
+ 'browser_open',
29
+ 'browser_click',
30
+ 'browser_type',
31
+ 'http_request',
32
+ 'fetch_url',
33
+ ]);
34
+
35
+ /** shell 文本里的联网命令(首词匹配,大小写不敏感)。 */
36
+ export const NETWORK_COMMANDS = new Set([
37
+ 'curl',
38
+ 'wget',
39
+ 'invoke-webrequest',
40
+ 'invoke-restmethod',
41
+ 'iwr',
42
+ 'irm',
43
+ 'start-bitstransfer',
44
+ 'npm',
45
+ 'pnpm',
46
+ 'yarn',
47
+ 'pip',
48
+ 'pip3',
49
+ 'git',
50
+ 'ssh',
51
+ 'scp',
52
+ 'sftp',
53
+ 'ftp',
54
+ 'telnet',
55
+ 'nc',
56
+ 'ncat',
57
+ 'aria2c',
58
+ ]);
59
+
60
+ /** git 里只有这几个子命令会碰网络;`git status` 不该被拦。 */
61
+ const GIT_NETWORK_SUBCOMMANDS = new Set(['clone', 'fetch', 'pull', 'push', 'ls-remote', 'submodule']);
62
+
63
+ /** npm/pnpm/yarn 里会碰网络的子命令;`npm test` 不该被拦。 */
64
+ const PM_NETWORK_SUBCOMMANDS = new Set([
65
+ 'install',
66
+ 'i',
67
+ 'add',
68
+ 'ci',
69
+ 'publish',
70
+ 'update',
71
+ 'up',
72
+ 'upgrade',
73
+ 'audit',
74
+ 'outdated',
75
+ 'view',
76
+ 'info',
77
+ 'search',
78
+ 'exec',
79
+ 'dlx',
80
+ ]);
81
+
82
+ /** pip 里会碰网络的子命令。 */
83
+ const PIP_NETWORK_SUBCOMMANDS = new Set(['install', 'download', 'wheel', 'search', 'index']);
84
+
85
+ export class ProfileError extends Error {}
86
+
87
+ /**
88
+ * 内置档:不需要在状态文件里先定义就能用。
89
+ * `strict` / `sealed` 的 allow 为空 —— 它们是**模板**,由所在目录的
90
+ * `.dsh-gate.json` 在**自己的子树内**填允许项(见 narrowProfile)。
91
+ */
92
+ export const BUILTIN_PROFILES = {
93
+ normal: { mode: 'blocklist' },
94
+ strict: { mode: 'allowlist', allow: [] },
95
+ sealed: { mode: 'sealed', allow: [] },
96
+ };
97
+
98
+ function asArray(value) {
99
+ return Array.isArray(value) ? value : [];
100
+ }
101
+
102
+ function normalizeLevel(level) {
103
+ return Object.prototype.hasOwnProperty.call(LEVEL_RANK, level) ? level : 'deny';
104
+ }
105
+
106
+ /** 规范化一条 allow 项;非法返回 null(不抛,便于"坏一条丢一条")。 */
107
+ export function normalizeAllowEntry(entry) {
108
+ if (!entry || typeof entry !== 'object') return null;
109
+ const path = typeof entry.path === 'string' ? entry.path.trim() : '';
110
+ if (!path) return null;
111
+ const resolved = win32.isAbsolute(path) ? win32.normalize(path) : null;
112
+ if (!resolved) return null;
113
+ return { path: resolved.replace(/[\\/]+$/, '') || resolved, level: normalizeLevel(entry.level) };
114
+ }
115
+
116
+ /**
117
+ * 规范化一个档。缺字段一律按"最严"补齐 —— 解析坏掉的档不该变成放行。
118
+ * @returns {{name:string, mode:string, allow:Array<{path:string,level:string}>, network:'allow'|'deny', unknownTool:'allow'|'deny'}}
119
+ */
120
+ export function normalizeProfile(name, raw) {
121
+ const def = raw && typeof raw === 'object' ? raw : {};
122
+ const mode = MODES.includes(def.mode) ? def.mode : 'sealed';
123
+ const allow = asArray(def.allow).map(normalizeAllowEntry).filter(Boolean);
124
+ const sealed = mode === 'sealed';
125
+ const network = sealed ? 'deny' : def.network === 'deny' ? 'deny' : 'allow';
126
+ const unknownTool = sealed || def.unknownTool === 'deny' ? 'deny' : 'allow';
127
+ return { name, mode, allow, network, unknownTool };
128
+ }
129
+
130
+ /**
131
+ * 从状态文件里取出所有档;兼容 v2(没有 profiles 字段 ⇒ 整体视作一个 blocklist 档)。
132
+ * @returns {{profiles: Map<string, object>, defaultProfile: string, sessionOverrides: Record<string,string>}}
133
+ */
134
+ export function readProfiles(state) {
135
+ const src = state && typeof state === 'object' ? state : {};
136
+ const profiles = new Map();
137
+ if (src.profiles && typeof src.profiles === 'object') {
138
+ for (const [name, def] of Object.entries(src.profiles)) {
139
+ profiles.set(name, normalizeProfile(name, def));
140
+ }
141
+ }
142
+ if (profiles.size === 0) {
143
+ // v2 兼容:objects 直接当 blocklist 档;同时带上内置的 strict / sealed 模板档。
144
+ profiles.set('normal', normalizeProfile('normal', { mode: 'blocklist', objects: src.objects }));
145
+ for (const [name, def] of Object.entries(BUILTIN_PROFILES)) {
146
+ if (name !== 'normal') profiles.set(name, normalizeProfile(name, def));
147
+ }
148
+ }
149
+ const defaultProfile =
150
+ typeof src.defaultProfile === 'string' && profiles.has(src.defaultProfile)
151
+ ? src.defaultProfile
152
+ : profiles.keys().next().value;
153
+ const sessionOverrides =
154
+ src.sessionOverrides && typeof src.sessionOverrides === 'object' ? { ...src.sessionOverrides } : {};
155
+ return { profiles, defaultProfile, sessionOverrides };
156
+ }
157
+
158
+ /**
159
+ * 选定本次调用用哪个档。
160
+ * @param {{profiles:Map,defaultProfile:string,sessionOverrides:Record}} state
161
+ * @param {{sessionId?:string, sessionDir?:string, dirBinding?:{profile?:string, allow?:any[]}}} hint
162
+ */
163
+ export function pickProfile(state, hint = {}) {
164
+ const { profiles, defaultProfile, sessionOverrides } = state;
165
+ const sid = hint.sessionId;
166
+ if (sid && typeof sessionOverrides[sid] === 'string' && profiles.has(sessionOverrides[sid])) {
167
+ return { profile: profiles.get(sessionOverrides[sid]), source: 'session' };
168
+ }
169
+ const binding = hint.dirBinding;
170
+ if (binding && typeof binding.profile === 'string' && profiles.has(binding.profile)) {
171
+ const base = profiles.get(binding.profile);
172
+ return { profile: narrowProfile(base, binding), source: 'dir' };
173
+ }
174
+ return { profile: profiles.get(defaultProfile), source: 'default' };
175
+ }
176
+
177
+ /**
178
+ * 目录绑定只能**加严**:引用已定义的档 + 只允许减少 allow 条目、只允许把 network/unknownTool 收紧。
179
+ * 传入一个想放宽的绑定 ⇒ 保持原档(并记一条 warning)。
180
+ */
181
+ export function narrowProfile(base, binding) {
182
+ const warnings = [];
183
+ const dir =
184
+ binding && typeof binding.dir === 'string'
185
+ ? win32.normalize(binding.dir).replace(/[\\/]+$/, '')
186
+ : null;
187
+ const insideDir = (p) =>
188
+ !dir ||
189
+ p.toLowerCase() === dir.toLowerCase() ||
190
+ p.toLowerCase().startsWith(dir.toLowerCase() + '\\');
191
+
192
+ const rawExtra = asArray(binding && binding.allow).map(normalizeAllowEntry).filter(Boolean);
193
+ const extra = rawExtra.filter((e) => insideDir(e.path));
194
+ if (extra.length !== rawExtra.length) {
195
+ warnings.push('目录绑定里有条目不在该配置文件所在子树内,已忽略(防止用配置解锁别处)');
196
+ }
197
+
198
+ let allow = base.allow;
199
+ if (base.mode !== 'blocklist' && base.allow.length === 0) {
200
+ // 模板档(strict / sealed 的 allow 为空):由本子树内的配置定义允许项
201
+ allow = extra;
202
+ } else if (extra.length) {
203
+ allow = extra
204
+ .map((e) => {
205
+ const b = base.allow.find((x) => x.path === e.path);
206
+ if (!b) return null; // 新增条目 = 放宽,忽略
207
+ return { path: b.path, level: LEVEL_RANK[e.level] < LEVEL_RANK[b.level] ? e.level : b.level };
208
+ })
209
+ .filter(Boolean);
210
+ if (allow.length !== extra.length) warnings.push('目录绑定试图新增允许项,已忽略');
211
+ }
212
+ const mode = base.mode === 'sealed' ? 'sealed' : binding && binding.mode === 'sealed' ? 'sealed' : base.mode;
213
+ const network = base.network === 'deny' ? 'deny' : binding && binding.network === 'deny' ? 'deny' : 'allow';
214
+ const unknownTool =
215
+ base.unknownTool === 'deny' || (binding && binding.unknownTool === 'deny') ? 'deny' : 'allow';
216
+ return { ...base, mode, allow, network, unknownTool, warnings };
217
+ }
218
+
219
+ /** 目标路径是否落在 allow 列表内(前缀匹配,按路径段边界,避免 D:\a 命中 D:\ab)。 */
220
+ export function matchAllow(profile, targetPath) {
221
+ if (!profile || !Array.isArray(profile.allow) || profile.allow.length === 0) return null;
222
+ if (typeof targetPath !== 'string' || !win32.isAbsolute(targetPath)) return null;
223
+ const target = win32.normalize(targetPath).replace(/[\\/]+$/, '') || targetPath;
224
+ let best = null;
225
+ for (const entry of profile.allow) {
226
+ const base = entry.path;
227
+ const same = target.toLowerCase() === base.toLowerCase();
228
+ const inside = target.toLowerCase().startsWith(base.toLowerCase() + '\\');
229
+ if (same || inside) {
230
+ if (!best || base.length > best.path.length) best = entry;
231
+ }
232
+ }
233
+ return best;
234
+ }
235
+
236
+ /** 联网工具名判定。 */
237
+ export function isNetworkTool(toolName) {
238
+ return typeof toolName === 'string' && NETWORK_TOOLS.has(toolName.toLowerCase());
239
+ }
240
+
241
+ /**
242
+ * shell 文本是否在"联网"。
243
+ * 只认首词与已知子命令,避免把 `npm test`、`git status` 误判成联网。
244
+ */
245
+ export function isNetworkCommand(commandText) {
246
+ if (typeof commandText !== 'string' || !commandText.trim()) return false;
247
+ // 取第一段(允许 `cd x && curl ...` 这种链式:逐段看)
248
+ const segments = commandText.split(/&&|\|\||[;|]/);
249
+ for (const segment of segments) {
250
+ const words = segment.trim().split(/\s+/).filter(Boolean);
251
+ if (words.length === 0) continue;
252
+ let head = words[0].toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
253
+ if (head === 'sudo' || head === 'doas') {
254
+ words.shift();
255
+ head = (words[0] || '').toLowerCase();
256
+ }
257
+ if (!NETWORK_COMMANDS.has(head)) continue;
258
+ if (head === 'git') {
259
+ const sub = (words[1] || '').toLowerCase();
260
+ if (GIT_NETWORK_SUBCOMMANDS.has(sub)) return true;
261
+ continue;
262
+ }
263
+ if (head === 'npm' || head === 'pnpm' || head === 'yarn') {
264
+ if (words.length === 1) return true; // 裸执行 npm 会打印帮助,不联网;保守起见仍当联网
265
+ const sub = words[1].toLowerCase();
266
+ if (PM_NETWORK_SUBCOMMANDS.has(sub)) return true;
267
+ continue;
268
+ }
269
+ if (head === 'pip' || head === 'pip3') {
270
+ const sub = (words[1] || '').toLowerCase();
271
+ if (PIP_NETWORK_SUBCOMMANDS.has(sub)) return true;
272
+ continue;
273
+ }
274
+ return true;
275
+ }
276
+ return false;
277
+ }
278
+
279
+ /**
280
+ * 判定一次工具调用。
281
+ * @param {object} profile 已规范化(可能经 narrowProfile)
282
+ * @param {{tool:string, targets:Array<{op:string,path:string}>, shell:boolean, command?:string, known:boolean}} call
283
+ * @returns {{allowed:boolean, reason:string, matched?:string}}
284
+ */
285
+ export function evaluateCall(profile, call) {
286
+ const p = profile || normalizeProfile('sealed', { mode: 'sealed' });
287
+ const tool = typeof call.tool === 'string' ? call.tool : '';
288
+ const targets = asArray(call.targets).filter((t) => t && typeof t.path === 'string');
289
+
290
+ if (p.mode === 'blocklist') return { allowed: true, reason: 'blocklist 档不限制未列出的对象' };
291
+
292
+ // allowlist / sealed:有路径就必须落在 allow 内
293
+ if (targets.length > 0) {
294
+ for (const t of targets) {
295
+ const hit = matchAllow(p, t.path);
296
+ if (!hit) {
297
+ return { allowed: false, reason: `不在本档允许的目录内(${p.name})`, matched: t.path };
298
+ }
299
+ const need = t.op === 'read' || t.op === 'traverse' ? 'read' : 'full';
300
+ if (LEVEL_RANK[hit.level] < LEVEL_RANK[need]) {
301
+ return { allowed: false, reason: `允许级别不足:${hit.path} 只给到 ${hit.level},本次需要 ${need}`, matched: hit.path };
302
+ }
303
+ }
304
+ }
305
+
306
+ if (p.mode === 'sealed') {
307
+ if (isNetworkTool(tool)) return { allowed: false, reason: '本档禁止联网(工具)', matched: tool };
308
+ if (call.shell && isNetworkCommand(call.command)) return { allowed: false, reason: '本档禁止联网(命令)', matched: call.command };
309
+ // shell 里认不出任何路径 ⇒ 无法确认它只碰允许目录,按拒绝处理
310
+ if (call.shell && targets.length === 0) {
311
+ return { allowed: false, reason: '无法确认这条命令会动哪些路径,按拒绝处理', matched: tool };
312
+ }
313
+ }
314
+
315
+ if (p.unknownTool === 'deny' && call.known === false) {
316
+ return { allowed: false, reason: '本档不认识这个工具,按拒绝处理', matched: tool };
317
+ }
318
+
319
+ return { allowed: true, reason: targets.length ? '落在允许目录内' : '无路径目标,规则未禁止' };
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-isolation-gate",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "门禁 · 目录访问管制(隔离区授权台):一屏管理「哪些目录受管、各自什么级别」,三级=禁/只读/可读写删;判定同时挂在受管辖通道(bin/gate.mjs)与宿主工具执行门(ctx.tools.guard)上,默认禁、名单缺失一律拒绝(fail-closed)、审计可查、agent 可自查;支持分组批量切、目录选择器与状态文件自动迁移。",
5
5
  "keywords": [
6
6
  "dsh",