@mrweicodes/dsh-permgate 1.3.9

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/index.js ADDED
@@ -0,0 +1,2451 @@
1
+ // dsh-permgate — 权限网关(宿主半)
2
+ // 注册 perm_* 工具、挂钩 tools/pre-execute 审查、经 webServer 提供 /permgate/* JSON 路由供浏览器 UI 调用。
3
+ // 配置持久化于 $DSH_HOME/dsh-permgate/config.json(用户级、不进任何 git 仓库)。
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
6
+ import { join as pathJoin } from 'node:path'
7
+ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, readdirSync as fsReaddirSync } from 'node:fs'
8
+ const CATS = ['directory', 'command', 'read', 'edit', 'subagent', 'doomloop']
9
+ const EXC_CATS = ['directory', 'command', 'read', 'edit']
10
+ const MODES = ['ask', 'allow', 'deny']
11
+ const ALL_MODES = ['ask', 'allow', 'deny', 'inherit']
12
+ const MAX_DECISIONS = 30
13
+ const QUICK_PRESET = ['web_search', 'skill', 'grep', 'glob', 'web_fetch']
14
+ const QUICK_DEFAULTS = { web_search: 'ask', skill: 'allow', grep: 'allow', glob: 'allow', web_fetch: 'ask' }
15
+ const ASK_TIMEOUT_MS = 300000 // 保留常量(历史/文档用途);审批已改为永不超时
16
+ const DECIDE_CHOICES = ['allow', 'deny', 'allow-global', 'allow-project', 'deny-global', 'deny-project']
17
+ const REPEAT_STREAK = 4
18
+ const PS_KEYWORDS = { foreach: 1, if: 1, else: 1, elseif: 1, for: 1, while: 1, do: 1, until: 1, switch: 1, return: 1, function: 1, filter: 1, param: 1, begin: 1, process: 1, end: 1, try: 1, catch: 1, finally: 1, throw: 1, break: 1, continue: 1, trap: 1, in: 1, not: 1, and: 1, or: 1, class: 1, enum: 1, using: 1, exit: 1, dynamicparam: 1, data: 1 }
19
+ // 子命令路由器命令族:候选细化到「git status *」这一粒度,而不是一放全放「git *」
20
+ const ROUTER_CMDS = { git: 1, npm: 1, pnpm: 1, yarn: 1, docker: 1, kubectl: 1, dotnet: 1, cargo: 1, go: 1, gh: 1, pip: 1, uv: 1, conda: 1 }
21
+
22
+ const FILE_READ_TOOLS = { read: 1, read_image: 1 }
23
+ const FILE_WRITE_TOOLS = { write: 1, edit: 1 }
24
+ const COMMAND_TOOLS = { pwsh: 1, bash: 1 }
25
+ const SUBAGENT_TOOLS = { subagent: 1, subagent_fork: 1, workflow: 1, ralph: 1 }
26
+
27
+ // 双语文案:bi(zh, en) 生成 {zh,en};L(obj, lang) 按语言取值(缺省回退中文)
28
+ const bi = (zh, en) => ({ zh, en })
29
+ const L = (o, lang) => (o && (o[lang] || o.zh)) || ''
30
+ // 语言参数归一化:只有 en 用英文,其余(缺失/空/非法)一律中文
31
+ const normLang = (v) => (v === 'en' ? 'en' : 'zh')
32
+
33
+ export default {
34
+ inject: ['fs', 'sandboxPolicy', 'tools', 'webServer', 'timer', 'approval', 'permissionPresets', 'sessions'],
35
+ apply(ctx) {
36
+ const fs = ctx.fs
37
+ const sp = ctx.sandboxPolicy
38
+ const disposers = []
39
+ const onDispose = (fn) => disposers.push(fn)
40
+ ctx.effect(() => () => { for (let i = disposers.length - 1; i >= 0; i--) { try { disposers[i]() } catch (e) {} } })
41
+
42
+ const fallbackRoot = String(sp.workspaceRoot || '').replace(/[\\/]+$/, '')
43
+ let root = norm(fallbackRoot)
44
+ let rootSource = 'policy'
45
+ let target = null
46
+ let loaded = false
47
+ let agentRef = null
48
+ let dshHomeCache = null
49
+ let config = freshConfig()
50
+ let loadError = null
51
+ let saveError = null
52
+ const decisions = []
53
+ const recent = []
54
+ const pendingApprovals = new Map()
55
+ // SSE 订阅者:/permgate/events 长连接的响应对象集合(状态/待审批变化即推)
56
+ const sseClients = new Set()
57
+ // 沙箱升级跟踪:{token: {session, prev}},工具执行完成后写回原沙箱(一次性升级)
58
+ const upgradedCalls = new Map()
59
+ // 客户端最近请求的语言(zh/en),用于宿主即时文案(弹窗标签、升级审批提示)
60
+ let uiLang = 'zh'
61
+
62
+ function norm(p) {
63
+ return String(p).replace(/\\/g, '/').replace(/\/+$/, '')
64
+ }
65
+
66
+ function safeJson(v) {
67
+ try { return JSON.stringify(v) } catch (e) { return '' }
68
+ }
69
+
70
+ function freshConfig() {
71
+ const g = { quickTools: {}, custom: [], sandboxMode: 'danger-full-access' }
72
+ for (const c of CATS) g[c] = freshCategory(c, false)
73
+ for (const k of Object.keys(QUICK_DEFAULTS)) g.quickTools[k] = QUICK_DEFAULTS[k]
74
+ return { global: g, projects: {} }
75
+ }
76
+
77
+ function freshProject() {
78
+ const pb = { quickTools: {}, custom: [], sandboxMode: 'inherit' }
79
+ for (const c of CATS) pb[c] = freshCategory(c, true)
80
+ return pb
81
+ }
82
+
83
+ function freshCategory(key, inheritDefault) {
84
+ const cat = { mode: inheritDefault ? 'inherit' : (key === 'directory' || key === 'command' || key === 'edit' || key === 'doomloop' ? 'ask' : 'allow') }
85
+ if (EXC_CATS.indexOf(key) !== -1) cat.exceptions = []
86
+ return cat
87
+ }
88
+
89
+ function normalizeException(r, key) {
90
+ if (!r || typeof r !== 'object') return null
91
+ if (MODES.indexOf(r.action) === -1) return null
92
+ const value = key === 'command' ? r.match : r.path
93
+ if (typeof value !== 'string' || !value) return null
94
+ const e = { id: r.id || 'e' + Math.random().toString(36).slice(2, 8), action: r.action }
95
+ // 仅 deny 例外支持自定义拒绝原因(allow 例外存了也用不上)
96
+ if (r.action === 'deny' && typeof r.reason === 'string' && r.reason.trim()) {
97
+ e.reason = r.reason.trim().slice(0, 200)
98
+ }
99
+ if (key === 'command') e.match = value
100
+ else e.path = value
101
+ return e
102
+ }
103
+
104
+ function normalizeCategory(raw, key, inheritDefault) {
105
+ const def = freshCategory(key, inheritDefault)
106
+ const c = raw && typeof raw === 'object' ? raw : {}
107
+ const cat = { mode: (inheritDefault ? ALL_MODES : MODES).indexOf(c.mode) !== -1 ? c.mode : def.mode }
108
+ if (EXC_CATS.indexOf(key) !== -1) {
109
+ cat.exceptions = Array.isArray(c.exceptions) ? c.exceptions.map((r) => normalizeException(r, key)).filter(Boolean) : []
110
+ }
111
+ return cat
112
+ }
113
+
114
+ function normalizeQuick(q) {
115
+ const out = {}
116
+ if (!q || typeof q !== 'object') return out
117
+ for (const k of Object.keys(q)) {
118
+ if (ALL_MODES.indexOf(q[k]) !== -1) out[k] = q[k]
119
+ }
120
+ return out
121
+ }
122
+
123
+ function normalizeRule(r) {
124
+ if (!r || typeof r !== 'object') return null
125
+ if (MODES.indexOf(r.action) === -1) return null
126
+ const rule = { id: r.id || 'r' + Math.random().toString(36).slice(2, 8), action: r.action }
127
+ if (r.tool !== undefined && r.tool !== null && r.tool !== '') rule.tool = String(r.tool)
128
+ if (r.path !== undefined && r.path !== null && r.path !== '') rule.path = String(r.path)
129
+ if (r.args !== undefined && r.args !== null && r.args !== '') rule.args = String(r.args)
130
+ if (r.reason !== undefined && r.reason !== null && r.reason !== '') rule.reason = String(r.reason)
131
+ return rule
132
+ }
133
+
134
+ function buildConfig(parsed) {
135
+ const g = parsed.global && typeof parsed.global === 'object' ? parsed.global : {}
136
+ const global = { quickTools: normalizeQuick(g.quickTools), custom: Array.isArray(g.custom) ? g.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access'].indexOf(g.sandboxMode) !== -1 ? g.sandboxMode : 'danger-full-access' }
137
+ for (const c of CATS) global[c] = normalizeCategory(g[c], c, false)
138
+ const projects = {}
139
+ const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
140
+ for (const key of Object.keys(rawProjects)) {
141
+ const p = rawProjects[key] && typeof rawProjects[key] === 'object' ? rawProjects[key] : {}
142
+ const pb = { quickTools: normalizeQuick(p.quickTools), custom: Array.isArray(p.custom) ? p.custom.map(normalizeRule).filter(Boolean) : [], sandboxMode: ['workspace-write', 'danger-full-access', 'inherit'].indexOf(p.sandboxMode) !== -1 ? p.sandboxMode : 'inherit' }
143
+ for (const c of CATS) pb[c] = normalizeCategory(p[c], c, true)
144
+ projects[key] = pb
145
+ }
146
+ return { global, projects }
147
+ }
148
+
149
+ function migrateOld(parsed) {
150
+ const g = parsed.global && typeof parsed.global === 'object' ? parsed.global : {}
151
+ const oldMode = ['off', 'permissive', 'locked'].indexOf(g.mode) !== -1 ? g.mode : 'off'
152
+ const cfg = freshConfig()
153
+ const map = { off: 'allow', permissive: 'allow', locked: 'deny' }
154
+ for (const c of CATS) cfg.global[c].mode = map[oldMode] || 'allow'
155
+ cfg.global.doomloop.mode = oldMode === 'off' ? 'allow' : 'ask'
156
+ if (oldMode === 'locked') {
157
+ for (const k of Object.keys(cfg.global.quickTools)) cfg.global.quickTools[k] = 'deny'
158
+ }
159
+ cfg.global.custom = Array.isArray(g.rules) ? g.rules.map(normalizeRule).filter(Boolean) : []
160
+ const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
161
+ for (const key of Object.keys(rawProjects)) {
162
+ const p = rawProjects[key] && typeof rawProjects[key] === 'object' ? rawProjects[key] : {}
163
+ const pm = ['off', 'permissive', 'locked'].indexOf(p.mode) !== -1 ? p.mode : 'off'
164
+ const pb = { quickTools: {}, custom: Array.isArray(p.rules) ? p.rules.map(normalizeRule).filter(Boolean) : [] }
165
+ for (const c of CATS) {
166
+ pb[c] = freshCategory(c, true)
167
+ pb[c].mode = pm === 'off' ? 'allow' : (map[pm] || 'allow')
168
+ }
169
+ pb.doomloop.mode = pm === 'off' ? 'allow' : 'ask'
170
+ if (pm === 'locked') {
171
+ for (const k of QUICK_PRESET) pb.quickTools[k] = 'deny'
172
+ }
173
+ cfg.projects[key] = pb
174
+ }
175
+ return cfg
176
+ }
177
+
178
+ function sessionPolicy() {
179
+ try { return sp.resolve ? sp.resolve() : null } catch (e) { return null }
180
+ }
181
+
182
+ function agentCwd(exec) {
183
+ try {
184
+ const agent = (exec && exec.agent) || agentRef
185
+ if (!agent || !agent.session || !agent.session.header) return undefined
186
+ const c = agent.session.header.cwd
187
+ return typeof c === 'string' && c ? norm(c) : undefined
188
+ } catch (e) { return undefined }
189
+ }
190
+
191
+ // 当前会话解析:工具执行上下文 → 最近权限事件会话 → 最后创建的会话(新窗口兜底)
192
+ function currentSession(exec) {
193
+ try {
194
+ const agent = (exec && exec.agent) || agentRef
195
+ if (agent && agent.session) return agent.session
196
+ if (ctx.sessions && typeof ctx.sessions.list === 'function') {
197
+ const all = ctx.sessions.list()
198
+ if (Array.isArray(all) && all.length) return all[all.length - 1]
199
+ }
200
+ } catch (e) {}
201
+ return null
202
+ }
203
+
204
+ // 当前会话选中的权限预设:显式 permission/preset 事件优先(保持既有语义,
205
+ // 即使 knobs 被手动改偏也按所选预设审查);无显式事件时用
206
+ // permissionPresets.current() 派生(覆盖仅由 knobs 决定的会话)。
207
+ // 兼容层:0.1.2 读 permissions 投影折叠态(permissionState API,等价于 0.1.1 的
208
+ // effectivePermissionPreset(events));0.1.1 无该 API 时自行折叠 session.events。
209
+ function explicitPreset(session) {
210
+ try {
211
+ const pp = ctx.permissionPresets
212
+ if (pp && typeof pp.permissionState === 'function') {
213
+ // 0.1.2:读取 permissions 投影折叠态得到显式选择
214
+ return pp.permissionState(session).preset || null
215
+ }
216
+ // 0.1.1 兼容:无 permissionState API 时自行折叠 session.events 日志,
217
+ // 取最后一个 permission/preset 事件(与 0.1.1 导出的
218
+ // effectivePermissionPreset(events) 同算法)。
219
+ const evs = session && session.events
220
+ if (Array.isArray(evs)) {
221
+ for (let i = evs.length - 1; i >= 0; i--) {
222
+ const e = evs[i]
223
+ if (e && e.type === 'permission/preset' && e.data) return e.data.preset || null
224
+ }
225
+ }
226
+ } catch (e) {}
227
+ return null
228
+ }
229
+
230
+ function sessionPresetName(exec) {
231
+ try {
232
+ const session = currentSession(exec)
233
+ if (!session) return null
234
+ const explicit = explicitPreset(session)
235
+ if (explicit) return explicit
236
+ const pp = ctx.permissionPresets
237
+ if (pp && typeof pp.current === 'function') {
238
+ // 0.1.2 起 current 收 session;0.1.1 收 events 数组(foldKnobs 遍历)。
239
+ // 以 permissionState API 是否存在作为 0.1.2 特征检测。
240
+ const arg = typeof pp.permissionState === 'function' ? session : (session && session.events)
241
+ const c = pp.current(arg)
242
+ return c === 'custom' ? null : c
243
+ }
244
+ } catch (e) {}
245
+ return null
246
+ }
247
+
248
+ // 底层沙箱有效值:项目非 inherit 用项目值,否则用全局值
249
+ function effectiveSandboxConfig() {
250
+ const proj = projectBlock()
251
+ const p = proj && proj.sandboxMode ? proj.sandboxMode : 'inherit'
252
+ if (p !== 'inherit') return p
253
+ return config.global.sandboxMode || 'danger-full-access'
254
+ }
255
+
256
+ function setSandboxConfig(target, mode) {
257
+ if (target === 'global') {
258
+ if (mode !== 'workspace-write' && mode !== 'danger-full-access') return false
259
+ config.global.sandboxMode = mode
260
+ return true
261
+ }
262
+ if (mode !== 'workspace-write' && mode !== 'danger-full-access' && mode !== 'inherit') return false
263
+ const block = ensureProject()
264
+ block.sandboxMode = mode
265
+ return true
266
+ }
267
+
268
+ // 会话处于「自定义审查」时,把解析后的底层沙箱同步为会话 sandbox
269
+ function syncSandbox(exec) {
270
+ try {
271
+ if (sessionPresetName(exec) !== 'custom-review') return
272
+ const mode = effectiveSandboxConfig()
273
+ const agent = (exec && exec.agent) || agentRef
274
+ const session = agent && agent.session
275
+ if (!session) return
276
+ const cur = sp.overrideOf(session)
277
+ if (cur !== mode) setSandboxMode(session, mode)
278
+ } catch (e) {
279
+ console.error('[permgate] syncSandbox error:', e)
280
+ }
281
+ }
282
+
283
+ // 写类工具 + 目标在工作区外 + 会话沙箱受限(workspace-write)→ 需要沙箱升级
284
+ function needsUpgrade(exec) {
285
+ try {
286
+ if (!FILE_WRITE_TOOLS[exec.name]) return false
287
+ const fp = pathArg(exec.arguments)
288
+ if (!fp || !isOutside(fp, root)) return false
289
+ const agent = (exec && exec.agent) || agentRef
290
+ const session = agent && agent.session
291
+ if (!session) return false
292
+ return sp.overrideOf(session) === 'workspace-write'
293
+ } catch (e) { return false }
294
+ }
295
+
296
+ // 发起 DSH 原生沙箱升级审批;批准后临时把会话沙箱设为 full access,
297
+ // 工具执行读到放开状态即可写工作区外;执行完成后由 post-execute 写回。
298
+ async function requireSandboxUpgrade(exec) {
299
+ try {
300
+ const agent = exec.agent
301
+ const session = agent && agent.session
302
+ if (!agent || !session) return false
303
+ const outcome = await ctx.approval.request({
304
+ agent,
305
+ toolName: exec.name,
306
+ callId: exec.callId,
307
+ reason: (uiLang === 'en' ? 'Sandbox upgrade required: write outside workspace ' : '需要沙箱升级:工作区外写入 ') + (pathArg(exec.arguments) || ''),
308
+ signal: exec.signal,
309
+ })
310
+ if (outcome !== 'allowed-once') return false
311
+ upgradedCalls.set(exec.token, { session, prev: sp.overrideOf(session) || 'workspace-write' })
312
+ setSandboxMode(session, 'danger-full-access')
313
+ return true
314
+ } catch (e) {
315
+ console.error('[permgate] requireSandboxUpgrade error:', e)
316
+ return false
317
+ }
318
+ }
319
+
320
+ // 兜底:异常/取消路径残留的升级在下次调用前写回
321
+ function flushStaleUpgrades() {
322
+ if (!upgradedCalls.size) return
323
+ for (const [tok, rec] of upgradedCalls) {
324
+ try { if (rec && rec.session) setSandboxMode(rec.session, rec.prev || 'workspace-write') } catch (e) {}
325
+ }
326
+ upgradedCalls.clear()
327
+ }
328
+
329
+ async function resolveDshHome() {
330
+ if (dshHomeCache !== null) return dshHomeCache
331
+ dshHomeCache = ''
332
+ try {
333
+ const sub = ctx.get('subprocess')
334
+ if (!sub) return dshHomeCache
335
+ const exe = await sub.resolveExecutable('cmd')
336
+ const tryEcho = async (expr) => {
337
+ const handle = sub.spawn({
338
+ argv: [exe, '/c', 'echo', expr],
339
+ cwd: String(root || 'C:\\').replace(/\//g, '\\'),
340
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 8192 }, stderr: { maxBytes: 8192 } },
341
+ graceMs: 5000,
342
+ })
343
+ await handle.done
344
+ const out = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
345
+ return String(out || '').trim()
346
+ }
347
+ let home = await tryEcho('%DSH_HOME%')
348
+ if (home && home.indexOf('%') === -1 && (home.indexOf(':') !== -1 || home.indexOf('/') === 0 || home.indexOf('\\') === 0)) {
349
+ dshHomeCache = norm(home)
350
+ return dshHomeCache
351
+ }
352
+ const profile = await tryEcho('%USERPROFILE%')
353
+ if (profile && profile.indexOf('%') === -1 && profile.indexOf(':') !== -1) {
354
+ dshHomeCache = norm(profile + '/.dsh')
355
+ return dshHomeCache
356
+ }
357
+ } catch (e) {
358
+ console.error('[permgate] resolveDshHome error:', e)
359
+ }
360
+ return dshHomeCache
361
+ }
362
+
363
+ async function ensureTarget(exec) {
364
+ const cwd = agentCwd(exec)
365
+ const pol = sessionPolicy()
366
+ const base = cwd || (pol && pol.workspaceRoot ? norm(String(pol.workspaceRoot)) : '') || (fallbackRoot ? norm(fallbackRoot) : '')
367
+ const source = cwd ? 'agent' : 'policy'
368
+ if (target && rootSource === source && norm(root) === norm(base)) return target
369
+ root = base
370
+ rootSource = source
371
+ const home = await resolveDshHome()
372
+ const abs = home ? home + '/dsh-permgate/config.json' : (base ? base + '/.dsh/.permgate.json' : '.dsh/.permgate.json')
373
+ target = await fs.resolve(abs)
374
+ return target
375
+ }
376
+
377
+ async function ensureConfigDir() {
378
+ try {
379
+ const home = await resolveDshHome()
380
+ if (home) {
381
+ const d = await fs.resolve(home + '/dsh-permgate')
382
+ const info = await fs.stat(d)
383
+ if (info) return true
384
+ const sub = ctx.get('subprocess')
385
+ if (!sub) return false
386
+ const exe = await sub.resolveExecutable('cmd')
387
+ const winPath = String(home + '/dsh-permgate').replace(/\//g, '\\')
388
+ const handle = sub.spawn({
389
+ argv: [exe, '/c', 'mkdir', winPath],
390
+ cwd: String(root || 'C:\\').replace(/\//g, '\\'),
391
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 8192 }, stderr: { maxBytes: 8192 } },
392
+ graceMs: 5000,
393
+ })
394
+ await handle.done
395
+ return true
396
+ }
397
+ const dir = root ? root + '/.dsh' : '.dsh'
398
+ const d = await fs.resolve(dir)
399
+ const info = await fs.stat(d)
400
+ if (info) return true
401
+ const sub = ctx.get('subprocess')
402
+ if (!sub) return false
403
+ const exe = await sub.resolveExecutable('cmd')
404
+ const winPath = String(dir).replace(/\//g, '\\')
405
+ const handle = sub.spawn({
406
+ argv: [exe, '/c', 'mkdir', winPath],
407
+ cwd: String(root || '.').replace(/\//g, '\\'),
408
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 8192 }, stderr: { maxBytes: 8192 } },
409
+ graceMs: 5000,
410
+ })
411
+ await handle.done
412
+ return true
413
+ } catch (e) {
414
+ console.error('[permgate] ensureConfigDir error:', e)
415
+ return false
416
+ }
417
+ }
418
+
419
+ async function init(exec) {
420
+ if (exec && exec.agent) agentRef = exec.agent
421
+ await ensureTarget(exec)
422
+ await ensureConfigDir()
423
+ if (!loaded) {
424
+ loaded = true
425
+ await load(exec)
426
+ await cleanupStaleProjects()
427
+ }
428
+ }
429
+
430
+ async function cleanupStaleProjects() {
431
+ try {
432
+ const projs = config.projects || {}
433
+ const keys = Object.keys(projs)
434
+ if (!keys.length) return
435
+ let removed = []
436
+ for (const key of keys) {
437
+ if (norm(key).toLowerCase() === norm(root).toLowerCase()) continue
438
+ let exists = false
439
+ try {
440
+ const d = await fs.resolve(norm(key))
441
+ const info = await fs.stat(d)
442
+ exists = !!info
443
+ } catch (e) { exists = false }
444
+ if (!exists) {
445
+ delete projs[key]
446
+ removed.push(key)
447
+ }
448
+ }
449
+ if (removed.length) {
450
+ console.log('[permgate] 清理失效工作区配置:', removed.join(', '))
451
+ await persist()
452
+ }
453
+ } catch (e) {
454
+ console.error('[permgate] cleanupStaleProjects error:', e)
455
+ }
456
+ }
457
+
458
+ function globToRegExp(glob) {
459
+ const g = norm(glob)
460
+ let out = ''
461
+ for (let i = 0; i < g.length; i++) {
462
+ const c = g[i]
463
+ if (c === '*') {
464
+ if (g[i + 1] === '*') { out += '.*'; i++ }
465
+ else out += '[^/]*'
466
+ } else if (c === '?') {
467
+ out += '[^/]'
468
+ } else if ('\\^$.[]{}()|+-'.indexOf(c) !== -1) {
469
+ out += '\\' + c
470
+ } else {
471
+ out += c
472
+ }
473
+ }
474
+ return new RegExp('^' + out + '$', 'i')
475
+ }
476
+
477
+ function matchGlob(glob, value) {
478
+ try { return globToRegExp(glob).test(norm(value)) } catch (e) { return false }
479
+ }
480
+
481
+ function matchCommand(pat, hay) {
482
+ const p = String(pat || '')
483
+ const h = String(hay || '')
484
+ if (p.indexOf('*') === -1 && p.indexOf('?') === -1) return h.toLowerCase().indexOf(p.toLowerCase()) !== -1
485
+ let body = p
486
+ let tail = '.*'
487
+ // 「cmd *」尾随通配:也匹配无参数的原命令(git status * 同时覆盖 git status)
488
+ if (p.endsWith(' *')) {
489
+ body = p.slice(0, -2)
490
+ tail = '( .*)?'
491
+ }
492
+ let re = ''
493
+ for (const c of body) {
494
+ if (c === '*') re += '.*'
495
+ else if (c === '?') re += '.'
496
+ else if ('\\^$.[]{}()|+-'.indexOf(c) !== -1) re += '\\' + c
497
+ else re += c
498
+ }
499
+ re += tail
500
+ try { return new RegExp('^' + re + '$', 'i').test(h) } catch (e) { return false }
501
+ }
502
+
503
+ function collectStrings(v, acc) {
504
+ if (typeof v === 'string') acc.push(v)
505
+ else if (Array.isArray(v)) { for (const x of v) collectStrings(x, acc) }
506
+ else if (v && typeof v === 'object') { for (const k of Object.keys(v)) collectStrings(v[k], acc) }
507
+ }
508
+
509
+ function ruleMatches(rule, name, args) {
510
+ if (rule.tool && !matchGlob(rule.tool, name)) return false
511
+ if (rule.path) {
512
+ const acc = []
513
+ collectStrings(args, acc)
514
+ if (!acc.some((s) => matchGlob(rule.path, s))) return false
515
+ }
516
+ if (rule.args) {
517
+ let hay = ''
518
+ try { hay = JSON.stringify(args) } catch (e) { hay = '' }
519
+ if (hay.toLowerCase().indexOf(String(rule.args).toLowerCase()) === -1) return false
520
+ }
521
+ return true
522
+ }
523
+
524
+ function projectBlock() {
525
+ const key = norm(root).toLowerCase()
526
+ const projs = config.projects || {}
527
+ for (const k of Object.keys(projs)) {
528
+ if (norm(k).toLowerCase() === key) return projs[k]
529
+ }
530
+ return undefined
531
+ }
532
+
533
+ function ensureProject() {
534
+ if (!config.projects[root]) config.projects[root] = freshProject()
535
+ return config.projects[root]
536
+ }
537
+
538
+ function setCategoryMode(targetKey, cat, mode) {
539
+ const allowed = targetKey === 'global' ? MODES : ALL_MODES
540
+ if (allowed.indexOf(mode) === -1) return false
541
+ const block = targetKey === 'global' ? config.global : ensureProject()
542
+ if (!block[cat]) block[cat] = freshCategory(cat, targetKey !== 'global')
543
+ block[cat].mode = mode
544
+ return true
545
+ }
546
+
547
+ function matchException(r, value, kind) {
548
+ if (kind === 'path') return matchGlob(r.path, value)
549
+ return matchCommand(r.match, value)
550
+ }
551
+
552
+ function resolveCategory(catKey, value, kind) {
553
+ const proj = projectBlock()
554
+ const pCat = proj ? proj[catKey] : undefined
555
+ const gCat = config.global[catKey] || freshCategory(catKey, false)
556
+ if (value !== null && value !== undefined && EXC_CATS.indexOf(catKey) !== -1) {
557
+ const pl = pCat && Array.isArray(pCat.exceptions) ? pCat.exceptions : []
558
+ for (const r of pl) if (matchException(r, value, kind)) return { action: r.action, ruleId: r.id, reason: (r.action === 'deny' && r.reason) ? r.reason : undefined }
559
+ const gl = Array.isArray(gCat.exceptions) ? gCat.exceptions : []
560
+ for (const r of gl) if (matchException(r, value, kind)) return { action: r.action, ruleId: r.id, reason: (r.action === 'deny' && r.reason) ? r.reason : undefined }
561
+ }
562
+ const mode = (pCat && pCat.mode && pCat.mode !== 'inherit') ? pCat.mode : (gCat.mode || 'allow')
563
+ return { action: mode, ruleId: null }
564
+ }
565
+
566
+ function pathArg(args) {
567
+ try {
568
+ if (!args || typeof args !== 'object') return null
569
+ // read/edit 工具用 path,write 工具用 file_path;两类都取,缺省取不到返回 null
570
+ if (typeof args.file_path === 'string') return args.file_path
571
+ if (typeof args.path === 'string') return args.path
572
+ return null
573
+ } catch (e) { return null }
574
+ }
575
+
576
+ // 工具参数里的文件路径可能是相对路径:fs 服务默认按自身 cwd 解析,会解析到错误位置
577
+ // (导致「文件不存在」/ 打不开文件)。这里把相对路径先按指定根(缺省用 permgate 项目
578
+ // root)归一化为绝对路径;绝对路径 / UNC / file:// 原样返回。
579
+ function resolveArgPath(fp, base) {
580
+ const s = norm(String(fp || ''))
581
+ if (s === '') return s
582
+ if (s.indexOf('://') !== -1) return s
583
+ if (/^[a-zA-Z]:[\\/]/.test(s)) return s
584
+ if (s[0] === '/') return s
585
+ const b = base || root
586
+ return b ? norm(b + '/' + s) : s
587
+ }
588
+
589
+ // open-file 允许打开的扩展名白名单(文本/文档类)。`cmd /c start` 对 Windows 上"运行"关联的
590
+ // 可执行/脚本类(exe/bat/cmd/ps1/vbs/js/py/msi/jar/lnk/svg 等)执行的是运行而非编辑,
591
+ // 白名单之外的扩展名一律拒绝,防止点击「打开文件」执行 agent 可控路径的脚本。
592
+ const OPEN_TEXT_EXTS = new Set([
593
+ 'txt', 'md', 'markdown', 'json', 'jsonc', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'log',
594
+ 'csv', 'tsv', 'xml', 'html', 'htm', 'css', 'ts', 'tsx', 'jsx', 'c', 'h', 'cpp', 'hpp', 'cc',
595
+ 'cxx', 'cs', 'java', 'go', 'rs', 'sql', 'gradle', 'properties',
596
+ ])
597
+
598
+ // ── 文件对比数据(按需路由 /permgate/file-diff 生成)───────────────
599
+ // 弹窗「详情」与右侧对比抽屉共用:edit/write 生成行级 Myers diff 操作流,
600
+ // 客户端渲染成带行号/底色的 unified diff(dsh-file-review 风格);read 返回
601
+ // 文件内容预览。软失败:内容过大/读取失败返回 {ok:false};变更行数或中间区
602
+ // 过大时走 fallback 旧式 ± 视图(前 200 变更行 + 截断计数,不阻塞审批)。
603
+ // DIFF_MAX_CHARS:对比双方文本总长上限(edit 为磁盘全文+新文本;write 为磁盘+内容)。
604
+ // 1MB 覆盖常见大文件(如打包产物);超限返回「文件过大,无法生成对比」。
605
+ const DIFF_MAX_CHARS = 1048576
606
+ const DIFF_MAX_LINES = 200
607
+ // Myers 中间区行数预算:超限走旧式 fallback(避免 trace 内存暴涨)。2048 行最坏时
608
+ // trace 累计约 33MB 瞬时分配 + 数百万次迭代(服务端主线程);512 行时约 2MB/数十万次,
609
+ // 足够覆盖常规编辑场景。
610
+ const DIFF_BUDGET_LINES = 512
611
+ // 行尾归一化(CRLF/CR → LF):splitDiffLines 与 edit 预览的归一化匹配共用同一规则,
612
+ // 保证 fileNorm.indexOf(oldNorm) 得到的偏移与 splitDiffLines 的行边界精确对齐
613
+ function normEol(s) {
614
+ return String(s == null ? '' : s).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
615
+ }
616
+ function splitDiffLines(s) {
617
+ return normEol(s).split('\n')
618
+ }
619
+ function computeLineDiff(oldText, newText) {
620
+ const oldLines = splitDiffLines(oldText)
621
+ const newLines = splitDiffLines(newText)
622
+ let prefix = 0
623
+ const maxP = Math.min(oldLines.length, newLines.length)
624
+ while (prefix < maxP && oldLines[prefix] === newLines[prefix]) prefix++
625
+ let suffix = 0
626
+ while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++
627
+ const removed = oldLines.slice(prefix, oldLines.length - suffix)
628
+ const added = newLines.slice(prefix, newLines.length - suffix)
629
+ const lines = []
630
+ let shownR = 0
631
+ let shownA = 0
632
+ const n = Math.max(removed.length, added.length)
633
+ for (let i = 0; i < n; i++) {
634
+ if (lines.length >= DIFF_MAX_LINES) break
635
+ // 行号:差异区从 prefix+1 行开始;删除行标旧文件行号,新增行标新文件行号
636
+ const no = String(prefix + i + 1).padStart(4, ' ')
637
+ if (i < removed.length) { lines.push('- ' + no + ' ' + removed[i]); shownR++ }
638
+ if (i < added.length) { lines.push('+ ' + no + ' ' + added[i]); shownA++ }
639
+ }
640
+ return { added: added.length, removed: removed.length, lines, truncated: (removed.length - shownR) + (added.length - shownA) }
641
+ }
642
+ // Myers 行级 diff:返回相对输入数组的 op 流(t: c/a/d,o/n: 1 基行号,s: 行文本)
643
+ function myersOps(a, b) {
644
+ const n = a.length
645
+ const m = b.length
646
+ // 双空输入防御:d 循环(d<=max=0)只跑 d=0 一轮且 done 置不齐,回溯 trace[1]
647
+ // 不存在 → undefined[-1] 崩溃;直接返回空 op 流。
648
+ if (n === 0 && m === 0) return []
649
+ const max = n + m
650
+ const off = max
651
+ const v = new Int32Array(2 * max + 1)
652
+ const trace = []
653
+ let d = 0
654
+ let done = false
655
+ for (; d <= max; d++) {
656
+ trace.push(v.slice())
657
+ for (let k = -d; k <= d; k += 2) {
658
+ let x
659
+ if (k === -d || (k !== d && v[k - 1 + off] < v[k + 1 + off])) x = v[k + 1 + off]
660
+ else x = v[k - 1 + off] + 1
661
+ let y = x - k
662
+ while (x < n && y < m && a[x] === b[y]) { x++; y++ }
663
+ v[k + off] = x
664
+ if (x >= n && y >= m) { done = true; break }
665
+ }
666
+ if (done) break
667
+ }
668
+ const ops = []
669
+ let x = n
670
+ let y = m
671
+ for (let di = d; di > 0; di--) {
672
+ const prev = trace[di]
673
+ const k = x - y
674
+ const kOff = k + off
675
+ let prevK
676
+ if (k === -di || (k !== di && prev[kOff - 1] < prev[kOff + 1])) prevK = k + 1
677
+ else prevK = k - 1
678
+ const px = prev[prevK + off]
679
+ const py = px - prevK
680
+ while (x > px && y > py) { ops.push({ t: 'c', o: x, n: y, s: a[x - 1] }); x--; y-- }
681
+ if (x === px) { ops.push({ t: 'a', o: null, n: y, s: b[y - 1] }); y-- }
682
+ else { ops.push({ t: 'd', o: x, n: null, s: a[x - 1] }); x-- }
683
+ }
684
+ while (x > 0 && y > 0) { ops.push({ t: 'c', o: x, n: y, s: a[x - 1] }); x--; y-- }
685
+ ops.reverse()
686
+ return ops
687
+ }
688
+ function parseEntryArgs(entry) {
689
+ try {
690
+ const v = JSON.parse(entry.argsJson || '{}')
691
+ return v && typeof v === 'object' ? v : {}
692
+ } catch (e) { return {} }
693
+ }
694
+ // 上下文运行折叠:>12 行时保留头尾各 3 行,中间折叠为 gap(c: 隐藏行数,lines: 可展开数据)。
695
+ // MAX_CTX 为 gap 携带的隐藏行上限(100000):未超过时 gap 携带完整行数据、可展开;
696
+ // 超过时 op.lines 为 null,客户端降级为「…」提示(pg2-gap-more)。pos 决定 gap 与
697
+ // 上下文的位置(避免 gap 前后都贴内容显得突兀):
698
+ // - lead(窗口/文件开头段):gap 在外侧,尾部 3 行贴改动侧
699
+ // - trail(结尾段):头部 3 行贴改动侧,gap 在外侧
700
+ // ≤12 行的小段直接全显示不折叠。
701
+ function pushCtxRun(out, lines, startOld, startNew, pos) {
702
+ const MAX_CTX = 100000
703
+ const FULL = 12
704
+ let o = startOld
705
+ let n = startNew
706
+ if (lines.length <= FULL) {
707
+ for (const s of lines) { out.push({ t: 'c', o, n, s }); o++; n++ }
708
+ return
709
+ }
710
+ const push = (arr) => {
711
+ for (const s of arr) { out.push({ t: 'c', o, n, s }); o++; n++ }
712
+ }
713
+ // 仅在隐藏行数不超过 MAX_CTX(需要携带行数据)时才做 slice/map,避免对超大上下文
714
+ // 段先整段复制再丢弃(服务端主线程瞬时大数组分配)。
715
+ const gap = (hidden, mk) => {
716
+ out.push({ t: 'g', c: hidden, lines: hidden <= MAX_CTX ? mk() : null })
717
+ o += hidden
718
+ n += hidden
719
+ }
720
+ if (pos === 'lead') {
721
+ gap(lines.length - 3, () => lines.slice(0, lines.length - 3).map((s, i) => ({ o: o + i, n: n + i, s })))
722
+ push(lines.slice(lines.length - 3))
723
+ } else if (pos === 'trail') {
724
+ push(lines.slice(0, 3))
725
+ gap(lines.length - 3, () => lines.slice(3).map((s, i) => ({ o: o + i, n: n + i, s })))
726
+ }
727
+ }
728
+ // 完整 diff payload(pretty 模式);超限时自动降级为旧式 fallback(不返回 null)。
729
+ // baseLine:窗口化对比时传入窗口首行的真实行号(默认 1),保证行号与文件实际位置一致。
730
+ function diffPayloadOrFallback(fp, oldText, newText, kind, baseLine) {
731
+ const base = baseLine || 1
732
+ const oldLines = splitDiffLines(oldText)
733
+ const newLines = splitDiffLines(newText)
734
+ let p = 0
735
+ const maxP = Math.min(oldLines.length, newLines.length)
736
+ while (p < maxP && oldLines[p] === newLines[p]) p++
737
+ let s = 0
738
+ while (s < oldLines.length - p && s < newLines.length - p && oldLines[oldLines.length - 1 - s] === newLines[newLines.length - 1 - s]) s++
739
+ const midA = oldLines.slice(p, oldLines.length - s)
740
+ const midB = newLines.slice(p, newLines.length - s)
741
+ // 完全相同(含空窗口):无差异,直接返回空 ops。
742
+ // 不能落入 myersOps([], []):其 d 循环读 v[off+1] 越界 undefined 置不齐 done,
743
+ // 回溯取 trace[d](d=1 时不存在)→ undefined[-1] 抛
744
+ // "Cannot read properties of undefined (reading '-1')"。
745
+ if (midA.length === 0 && midB.length === 0) {
746
+ return { ok: true, kind, file: fp, added: 0, removed: 0, ops: [], truncated: 0 }
747
+ }
748
+ // 廉价预过滤:|midA.length - midB.length| > DIFF_MAX_LINES 时 added+removed 必超 200
749
+ // (added - removed === midB.length - midA.length),Myers 结果必被丢弃,直接走 fallback,
750
+ // 避免无谓的 O((N+M)*D) 计算与 trace 内存。
751
+ if (Math.abs(midA.length - midB.length) > DIFF_MAX_LINES) {
752
+ const d = computeLineDiff(oldText, newText)
753
+ return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
754
+ }
755
+ if (midA.length + midB.length <= DIFF_BUDGET_LINES) {
756
+ const ops = myersOps(midA, midB)
757
+ let added = 0
758
+ let removed = 0
759
+ for (const op of ops) {
760
+ if (op.t === 'a') added++
761
+ else if (op.t === 'd') removed++
762
+ }
763
+ if (added + removed <= DIFF_MAX_LINES) {
764
+ const out = []
765
+ pushCtxRun(out, oldLines.slice(0, p), base, base, 'lead')
766
+ for (const op of ops) out.push({ t: op.t, o: op.o === null ? null : op.o + p + base - 1, n: op.n === null ? null : op.n + p + base - 1, s: op.s })
767
+ let curO = base + p
768
+ let curN = base + p
769
+ for (const op of ops) {
770
+ if (op.o !== null) curO++
771
+ if (op.n !== null) curN++
772
+ }
773
+ pushCtxRun(out, oldLines.slice(oldLines.length - s), curO, curN, 'trail')
774
+ return { ok: true, kind, file: fp, added, removed, ops: out, truncated: 0 }
775
+ }
776
+ }
777
+ const d = computeLineDiff(oldText, newText)
778
+ return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
779
+ }
780
+ // 新文件(write 到不存在路径):全部为新增行
781
+ function newFilePayload(fp, content) {
782
+ const total = splitDiffLines(content).length
783
+ if (total > DIFF_MAX_LINES) {
784
+ const lines = splitDiffLines(content).slice(0, DIFF_MAX_LINES).map((l, i) => '+ ' + String(i + 1).padStart(4, ' ') + ' ' + l)
785
+ return { ok: true, kind: 'new', file: fp, fallback: true, added: total, removed: 0, lines, truncated: total - lines.length }
786
+ }
787
+ const ops = splitDiffLines(content).map((s, i) => ({ t: 'a', o: null, n: i + 1, s }))
788
+ return { ok: true, kind: 'new', file: fp, added: total, removed: 0, ops, truncated: 0 }
789
+ }
790
+
791
+ // ── dsh-better-edit 兼容:hash 锚点 edit 的 diff 预览 ──────────────────
792
+ // better-edit 的 edit 参数是 {path, edits:[[remove_from,remove_to,replacement_text],...]},
793
+ // 锚点是 3 字符行 hash(来自 read 输出的 HASH│ 前缀)。要生成 diff 需把 hash 映射回行号:
794
+ // better-edit 把每行 hash 持久化在 ~/.dsh/plugins/dsh-better-edit/runtime/<ws>-<hash8>/hash-store.sqlite
795
+ // (snapshots 表:path → hashes JSON 数组,按行对应)。permgate 扫描 runtime 目录、用 .wsPath
796
+ // sidecar 匹配项目根,读目标文件的 hashes,再按 [start,end] 行区间应用替换。
797
+ // 任何一步失败(store 不存在/无快照/锚点失效)→ 降级为补丁意图展示,不阻塞审批。
798
+ const BETTER_EDIT_ANCHOR_RE = /^([+-]?)([A-Za-z0-9]{3})[│|]/i
799
+ function betterEditAnchor(ref) {
800
+ try {
801
+ const s = String(ref || '').trim()
802
+ const m = s.match(BETTER_EDIT_ANCHOR_RE)
803
+ if (m) return m[2]
804
+ if (/^[A-Za-z0-9]{3}$/.test(s)) return s
805
+ return null
806
+ } catch (e) { return null }
807
+ }
808
+ // 扫描 better-edit runtime 目录,返回匹配 projRoot 的 store 路径(.wsPath sidecar 匹配)
809
+ function betterEditStoreFor(projRoot) {
810
+ try {
811
+ let base = process.env.DSH_HOME || (process.env.HOME || process.env.USERPROFILE)
812
+ if (!base) return null
813
+ // DSH_HOME 已含 .dsh(如 C:\Users\71026\.dsh)时不再重复拼接
814
+ if (!/[/\\]\.dsh$/.test(base)) base = pathJoin(base, '.dsh')
815
+ const rt = pathJoin(base, 'plugins', 'dsh-better-edit', 'runtime')
816
+ if (!fsExistsSync(rt)) return null
817
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
818
+ const want = norm(projRoot)
819
+ for (const dir of fsReaddirSync(rt)) {
820
+ const full = pathJoin(rt, dir)
821
+ const wsPath = pathJoin(full, '.wsPath')
822
+ let ws = null
823
+ try { ws = fsReadFileSync(wsPath, 'utf8').trim() } catch (e) {}
824
+ if (ws && norm(ws) === want) {
825
+ const store = pathJoin(full, 'hash-store.sqlite')
826
+ return fsExistsSync(store) ? store : null
827
+ }
828
+ }
829
+ return null
830
+ } catch (e) { return null }
831
+ }
832
+ // 从 better-edit store 读取目标文件的 hashes 数组(按行)。path 匹配做大小写/斜杠归一化。
833
+ async function betterEditHashesFor(projRoot, targetPath) {
834
+ const storePath = betterEditStoreFor(projRoot)
835
+ if (!storePath) return null
836
+ try {
837
+ const { DatabaseSync } = await import('node:sqlite')
838
+ const normKey = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
839
+ const want = normKey(targetPath)
840
+ const db = new DatabaseSync(storePath, { readOnly: true })
841
+ try {
842
+ const rows = db.prepare('SELECT path, hashes FROM snapshots').all()
843
+ for (const row of rows) {
844
+ if (normKey(row.path) === want) {
845
+ try {
846
+ const arr = JSON.parse(row.hashes)
847
+ if (Array.isArray(arr)) return arr
848
+ } catch (e) {}
849
+ return null
850
+ }
851
+ }
852
+ return null
853
+ } finally { try { db.close() } catch (e) {} }
854
+ } catch (e) { return null }
855
+ }
856
+
857
+ // ── 从磁盘内容重算 better-edit 行 hash ─────────────────────────────
858
+ // store 快照可能过期(文件在 read 后被改):此时按 better-edit 的 hash 算法
859
+ // (canon → xxh32(seed=0) → probe 分配,见 hashline/hash-assign.js)从磁盘全文重算,
860
+ // 这样锚点总能映射到当前文件。xxh32 用 xxhash-wasm 的 wasm 实现(file URL 动态加载,
861
+ // 绕过 pnpm 隔离;加载失败自动降级返回 null,走原有降级路径)。
862
+ const BE_ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
863
+ const BE_HASH_LEN = 3
864
+ const BE_HASH_SPACE = BE_ALPH.length ** BE_HASH_LEN
865
+ const BE_PROBE_STRIDE = BE_ALPH.length ** 2 + BE_ALPH.length + 1
866
+ const BE_BITSET_WORDS = Math.ceil(BE_HASH_SPACE / 32)
867
+ let beHasherP = null
868
+ function beIdxToHash(idx) {
869
+ let out = ''
870
+ for (let j = 0; j < BE_HASH_LEN; j++) { out = BE_ALPH[idx % BE_ALPH.length] + out; idx = Math.floor(idx / BE_ALPH.length) }
871
+ return out
872
+ }
873
+ function beCanon(line) { return String(line || '').replace(/[ \t\r\n]+/g, '') }
874
+ function beLoadHasher() {
875
+ if (beHasherP) return beHasherP
876
+ beHasherP = (async () => {
877
+ // 在 better-edit 的安装树里找 xxhash-wasm 的 esm 入口
878
+ const homedir = process.env.DSH_HOME || (process.env.HOME || process.env.USERPROFILE)
879
+ const base = /[/\\]\.dsh$/.test(homedir) ? homedir : pathJoin(homedir, '.dsh')
880
+ const profileNm = pathJoin(base, 'profiles', 'web', 'node_modules', '.pnpm')
881
+ const dirs = fsExistsSync(profileNm) ? fsReaddirSync(profileNm) : []
882
+ let entry = null
883
+ for (const d of dirs) {
884
+ if (d.indexOf('xxhash-wasm@') !== 0) continue
885
+ const cand = pathJoin(profileNm, d, 'node_modules', 'xxhash-wasm', 'esm', 'xxhash-wasm.js')
886
+ if (fsExistsSync(cand)) { entry = cand; break }
887
+ }
888
+ if (!entry) return null
889
+ const mod = await import('file:///' + entry.replace(/\\/g, '/'))
890
+ const api = await mod.default()
891
+ return api.h32 || null
892
+ })().catch(() => null)
893
+ return beHasherP
894
+ }
895
+ // 复刻 lineHashesPure:返回每行 3 字符 hash(未剥离 BOM——调用方先 strip)
896
+ async function betterEditHashesFromDisk(fileText) {
897
+ const h32 = await beLoadHasher()
898
+ if (!h32) return null
899
+ try {
900
+ const norm = String(fileText || '').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
901
+ const lines = norm.split('\n')
902
+ const hashes = new Array(lines.length)
903
+ const used = new Uint32Array(BE_BITSET_WORDS)
904
+ let hint = 0
905
+ const getBit = (idx) => (used[idx >>> 5] >>> (idx & 31) & 1) !== 0
906
+ const setBit = (idx) => { used[idx >>> 5] |= 1 << (idx & 31) }
907
+ const nextZero = (start) => {
908
+ let idx = start % BE_HASH_SPACE
909
+ for (let i = 0; i < BE_HASH_SPACE; i++) {
910
+ if (!getBit(idx)) return idx
911
+ idx += BE_PROBE_STRIDE
912
+ if (idx >= BE_HASH_SPACE) idx -= BE_HASH_SPACE
913
+ }
914
+ return -1
915
+ }
916
+ for (let i = 0; i < lines.length; i++) {
917
+ const base = (h32(beCanon(lines[i]), 0) >>> 14) % BE_HASH_SPACE
918
+ if (!getBit(base)) {
919
+ setBit(base); hint = base + BE_PROBE_STRIDE; hashes[i] = beIdxToHash(base)
920
+ } else {
921
+ const nxt = nextZero(hint)
922
+ if (nxt < 0) return null
923
+ setBit(nxt); hint = nxt + BE_PROBE_STRIDE; hashes[i] = beIdxToHash(nxt)
924
+ }
925
+ }
926
+ return hashes
927
+ } catch (e) { return null }
928
+ }
929
+
930
+ // 顺序应用 better-edit edits(hash 锚点 → 行号区间替换),返回 { text, minLine, maxLine };
931
+ // 任一锚点失效返回 null。语义对齐 better-edit 的稳定重哈希:应用一个 edit 后,
932
+ // 未变行保留原 hash(后续锚点可继续解析),被删行失去 hash,新增行无法预知 hash
933
+ // (agent 只能引用 read 时的旧锚点,指向新增行必然失效 → 与 better-edit 实际行为一致)。
934
+ function applyBetterEdits(fileText, edits, hashes, only) {
935
+ try {
936
+ // only:可选索引集合,只应用这些 edit(供分窗口 diff:每组只看自己的变更)
937
+ const indices = only ? [...only].sort((a, b) => a - b) : edits.map((_, i) => i)
938
+ const normEolTxt = String(fileText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
939
+ const lines = normEolTxt.split('\n')
940
+ // curHash[i]:当前 lines[i] 的 hash(未变行保持原 hash;被删/新增行置 null)
941
+ const curHash = hashes.slice()
942
+ if (curHash.length < lines.length) curHash.length = lines.length
943
+ let minLine = Infinity
944
+ let maxLine = -Infinity
945
+ for (const idx of indices) {
946
+ const raw = edits[idx]
947
+ let e = raw
948
+ if (Array.isArray(raw) && raw.length >= 3) e = { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] }
949
+ const fromHash = betterEditAnchor(e && e.remove_from)
950
+ const toHash = betterEditAnchor(e && e.remove_to)
951
+ const repl = (e && typeof e.replacement_text === 'string') ? e.replacement_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''
952
+ if (!fromHash || !toHash) return null
953
+ // 在当前(已部分应用)的内容上找锚点:仅未变行的原 hash 有效
954
+ const start = curHash.indexOf(fromHash)
955
+ const end = curHash.indexOf(toHash)
956
+ if (start === undefined || start < 0 || end === undefined || end < 0) return null
957
+ const s = Math.min(start, end)
958
+ const t = Math.max(start, end)
959
+ if (s < 0 || t >= lines.length) return null
960
+ const replLines = repl === '' ? [] : repl.split('\n')
961
+ // 记录变更范围(1 基行号,用当前行号;最终窗口按原始锚点行换算见调用方)
962
+ if (s + 1 < minLine) minLine = s + 1
963
+ if (t + 1 > maxLine) maxLine = t + 1
964
+ // 替换区间:删除 [s..t],插入 replLines
965
+ lines.splice(s, t - s + 1, ...replLines)
966
+ // 稳定重哈希:区间内原 hash 删除;区间后未变行 hash 平移保留;新增行 hash 未知(null)
967
+ const removedHashes = curHash.slice(s, t + 1)
968
+ const tailHashes = curHash.slice(t + 1)
969
+ curHash.length = s
970
+ for (let i = 0; i < removedHashes.length; i++) curHash[s + i] = null
971
+ for (let i = 0; i < replLines.length; i++) curHash[s + i] = null
972
+ for (let i = 0; i < tailHashes.length; i++) curHash[s + replLines.length + i] = tailHashes[i]
973
+ // 清理尾部空洞
974
+ while (curHash.length > lines.length) curHash.pop()
975
+ while (curHash.length > 0 && curHash[curHash.length - 1] === null) curHash.pop()
976
+ }
977
+ return { text: lines.join('\n'), minLine, maxLine }
978
+ } catch (e) { return null }
979
+ }
980
+
981
+ // 按审批 entry 生成对比数据(/permgate/file-diff 路由用;失败返回 {ok:false,error},不支持返回 null)
982
+ async function buildFileDiffData(entry, fsService) {
983
+ const name = entry.tool
984
+ const args = parseEntryArgs(entry)
985
+ const fp = pathArg(args)
986
+ if (FILE_READ_TOOLS[name]) {
987
+ if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
988
+ try {
989
+ const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
990
+ const info = await fsService.stat(target)
991
+ if (info === undefined) return { ok: false, error: bi('文件不存在', 'File not found') }
992
+ if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
993
+ // 窗口化读取:只取 offset/limit 附近(前后各 W 行)的内容,流式消费到窗口末尾即停,
994
+ // 不整读大文件;末尾省略行数未知,由客户端显示通用提示。
995
+ // 资源上限:offset/limit 来自 agent 工具参数(不可信),且文件中可能存在无换行的
996
+ // 极长行,故对窗口行数(MAX_LIMIT)、返回文本总字节(MAX_BYTES)与单行长度
997
+ // (MAX_LINE)设硬上限,超限即截断并标记省略,避免服务端主线程无界内存分配。
998
+ const W = 200
999
+ const MAX_LIMIT = 4096
1000
+ const MAX_BYTES = 262144
1001
+ const MAX_LINE = 65536
1002
+ const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 1
1003
+ const limit = Math.min(Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 200, MAX_LIMIT)
1004
+ const winStart = Math.max(1, offset - W)
1005
+ const winEnd = offset + limit - 1 + W
1006
+ const out = []
1007
+ let outBytes = 0
1008
+ let cut = false
1009
+ let buf = ''
1010
+ let line = 0
1011
+ let done = false
1012
+ let sawMore = false
1013
+ outer:
1014
+ for await (const chunk of await fsService.streamText(target)) {
1015
+ buf += chunk
1016
+ // 无换行的极长行:只保留尾部片段,防止 buf 无界增长;该行内容被截断时标记省略
1017
+ if (buf.indexOf('\n') === -1 && buf.length > MAX_LINE) { cut = true; buf = buf.slice(buf.length - MAX_LINE) }
1018
+ let nl
1019
+ while ((nl = buf.indexOf('\n')) !== -1) {
1020
+ line++
1021
+ // 窗口末行之后确认还有内容才标记「下方还有更多行」(文件恰好结束在窗口边界时不误报)
1022
+ if (done) { sawMore = true; break outer }
1023
+ if (line >= winStart && line <= winEnd) {
1024
+ if (outBytes < MAX_BYTES) {
1025
+ let s = buf.slice(0, nl)
1026
+ if (s.length > MAX_LINE) { s = s.slice(0, MAX_LINE); cut = true }
1027
+ out.push(s)
1028
+ outBytes += s.length
1029
+ } else { cut = true; break outer }
1030
+ }
1031
+ buf = buf.slice(nl + 1)
1032
+ if (line >= winEnd) done = true
1033
+ }
1034
+ }
1035
+ if (done && buf !== '') sawMore = true
1036
+ if (!done && buf !== '') {
1037
+ line++
1038
+ if (line >= winStart && line <= winEnd) {
1039
+ if (outBytes < MAX_BYTES) {
1040
+ let s = buf
1041
+ if (s.length > MAX_LINE) { s = s.slice(0, MAX_LINE); cut = true }
1042
+ out.push(s)
1043
+ outBytes += s.length
1044
+ } else {
1045
+ cut = true
1046
+ }
1047
+ }
1048
+ }
1049
+ return { ok: true, kind: 'read', file: fp, text: out.join('\n'), startLine: winStart, topOmitted: winStart > 1 && line >= winStart, bottomOmitted: sawMore || cut }
1050
+ } catch (e) {
1051
+ // 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
1052
+ // 返回双语对象,由路由侧 L(r.error, lang) 按语言取值。
1053
+ const emsg = (e && e.message ? e.message : String(e))
1054
+ return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
1055
+ }
1056
+ }
1057
+ if (FILE_WRITE_TOOLS[name]) {
1058
+ if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
1059
+ if (name === 'edit') {
1060
+ // dsh-better-edit 兼容:{path, edits:[[remove_from,remove_to,replacement_text],...]} hash 锚点格式。
1061
+ // 与旧格式(old_string/new_string)互斥,优先识别 edits 数组。
1062
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
1063
+ try {
1064
+ const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
1065
+ const info = await fsService.stat(target)
1066
+ if (info === undefined) return { ok: false, error: bi('文件不存在', 'File not found') }
1067
+ if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
1068
+ const fileText = await fsService.readText(target)
1069
+ if (fileText.length > DIFF_MAX_CHARS) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1070
+ // 1) 优先 store 快照;2) 快照过期则从磁盘内容重算(xxh32 复刻);3) 都失败再降级
1071
+ let hashes = await betterEditHashesFor(entry.projRoot, target)
1072
+ if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
1073
+ hashes = await betterEditHashesFromDisk(fileText)
1074
+ }
1075
+ if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
1076
+ // 无任何 hash 来源:无法映射锚点,退回补丁意图展示(至少显示替换文本)
1077
+ const intent = args.edits.map((e) => {
1078
+ const arr = Array.isArray(e) ? e : null
1079
+ return arr ? arr[2] : (e && e.replacement_text) || ''
1080
+ }).join('\n')
1081
+ return diffPayloadOrFallback(fp, '', intent, 'modified')
1082
+ }
1083
+ const applied = applyBetterEdits(fileText, args.edits, hashes)
1084
+ if (applied === null) {
1085
+ // 锚点失效或 store 与磁盘不一致:退回补丁意图展示
1086
+ const intent = args.edits.map((e) => {
1087
+ const arr = Array.isArray(e) ? e : null
1088
+ return arr ? arr[2] : (e && e.replacement_text) || ''
1089
+ }).join('\n')
1090
+ return diffPayloadOrFallback(fp, '', intent, 'modified')
1091
+ }
1092
+ // 分窗口 diff:把相距较远的 edits 分成多组(相邻间隔 ≤ 2W 同组),
1093
+ // 每组独立生成一个小窗口 diff 再拼接——避免单个大窗口把中间大段未变内容
1094
+ // 算成 +N/-N 假变更(如 +300 -300)。
1095
+ // 关键:new 侧不从「整文件应用后按行号切片」——行数变化(如 1 行换 52 行)会让
1096
+ // new 侧整体偏移,窗口尾部与 old 错位,把大量未变行误判为变更(+52/-52、+342/-342
1097
+ // 等假象)。改为以 old 窗口行为基底、仅在该窗口内应用本组 edits(跟踪 offset),
1098
+ // 使 old/new 覆盖同一内容区域、行号天然对齐。
1099
+ const W = 200
1100
+ const oldLines = splitDiffLines(String(fileText).replace(/\r\n/g, '\n').replace(/\r/g, '\n'))
1101
+ // 每个 edit 的原始行区间(0 基)
1102
+ const editRanges = args.edits.map((raw) => {
1103
+ const e = Array.isArray(raw) && raw.length >= 3 ? { remove_from: raw[0], remove_to: raw[1] } : raw
1104
+ const a = betterEditAnchor(e && e.remove_from)
1105
+ const b = betterEditAnchor(e && e.remove_to)
1106
+ let s = -1, t = -1
1107
+ if (a && hashes) { const i = hashes.indexOf(a); if (i >= 0) s = i }
1108
+ if (b && hashes) { const i = hashes.indexOf(b); if (i >= 0) t = i }
1109
+ if (s < 0 && t >= 0) s = t
1110
+ if (t < 0 && s >= 0) t = s
1111
+ return { s, t }
1112
+ })
1113
+ // 分组:按起始行排序,间隔 > 2W 开新组
1114
+ const order = args.edits.map((_, i) => i).sort((x, y) => editRanges[x].s - editRanges[y].s)
1115
+ const groups = []
1116
+ let cur = null
1117
+ for (const i of order) {
1118
+ const line = editRanges[i].s
1119
+ if (line < 0) continue
1120
+ if (!cur || line - cur.max > 2 * W) {
1121
+ cur = { min: line, max: line, indices: [i] }
1122
+ groups.push(cur)
1123
+ } else {
1124
+ cur.max = Math.max(cur.max, line)
1125
+ cur.indices.push(i)
1126
+ }
1127
+ }
1128
+ const allOps = []
1129
+ let totalAdded = 0
1130
+ let totalRemoved = 0
1131
+ for (const g of groups) {
1132
+ let gMin0 = Infinity, gMax0 = -Infinity
1133
+ for (const i of g.indices) {
1134
+ const r = editRanges[i]
1135
+ if (r.s < gMin0) gMin0 = r.s
1136
+ if (r.t > gMax0) gMax0 = r.t
1137
+ }
1138
+ if (gMin0 === Infinity) continue
1139
+ const gMin = Math.max(1, gMin0 + 1 - W)
1140
+ const gOldEnd = Math.min(oldLines.length, gMax0 + 1 + W)
1141
+ // 新侧:以 old 窗口为基底,仅应用本组 edits,跟踪 offset
1142
+ const local = oldLines.slice(gMin - 1, gOldEnd)
1143
+ let off = 0
1144
+ let resolved = true
1145
+ for (const i of g.indices) {
1146
+ const r = editRanges[i]
1147
+ const raw = args.edits[i]
1148
+ const e = Array.isArray(raw) && raw.length >= 3 ? { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] } : raw
1149
+ const repl = (e && typeof e.replacement_text === 'string') ? e.replacement_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''
1150
+ const ls = r.s - (gMin - 1) + off
1151
+ const lt = r.t - (gMin - 1) + off
1152
+ if (ls < 0 || lt < ls || lt > local.length) { resolved = false; break }
1153
+ const replLines = repl === '' ? [] : repl.split('\n')
1154
+ local.splice(ls, lt - ls + 1, ...replLines)
1155
+ off += replLines.length - (lt - ls + 1)
1156
+ }
1157
+ if (!resolved) continue
1158
+ const oldWin = oldLines.slice(gMin - 1, gOldEnd).join('\n')
1159
+ const newWin = local.join('\n')
1160
+ const p = diffPayloadOrFallback(fp, oldWin, newWin, 'modified', gMin)
1161
+ if (!p || !p.ok) continue
1162
+ if (p.fallback) return p
1163
+ totalAdded += p.added || 0
1164
+ totalRemoved += p.removed || 0
1165
+ if (Array.isArray(p.ops)) {
1166
+ for (const op of p.ops) allOps.push(op)
1167
+ } else if (p.lines) {
1168
+ return p
1169
+ }
1170
+ }
1171
+ if (allOps.length === 0 && totalAdded === 0 && totalRemoved === 0) {
1172
+ return diffPayloadOrFallback(fp, oldLines.join('\n'), splitDiffLines(applied.text).join('\n'), 'modified', 1)
1173
+ }
1174
+ return { ok: true, kind: 'modified', file: fp, added: totalAdded, removed: totalRemoved, ops: allOps, truncated: 0, grouped: true }
1175
+ } catch (e) {
1176
+ const emsg = (e && e.message ? e.message : String(e))
1177
+ return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
1178
+ }
1179
+ }
1180
+ const oldText = typeof args.old_string === 'string' ? args.old_string : ''
1181
+ const newText = typeof args.new_string === 'string' ? args.new_string : ''
1182
+ if (oldText.length + newText.length > DIFF_MAX_CHARS) return { ok: false, error: bi('内容过大,无法生成对比', 'Content too large to compare') }
1183
+ // 关键:edit 是补丁式,仅对比 old_string/new_string 会丢失文件上下文(抽屉只会显示
1184
+ // 补丁那几行)。改为读取磁盘当前内容、应用补丁后,取改动前后各 W 行的窗口做 diff——
1185
+ // 行号从真实位置起算,payload 恒定小,大文件无需整文件对比(write 才是整文件语义)。
1186
+ try {
1187
+ const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
1188
+ const info = await fsService.stat(target)
1189
+ if (info === undefined) return { ok: false, error: bi('文件不存在', 'File not found') }
1190
+ if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
1191
+ if (info.size !== undefined && info.size + newText.length > DIFF_MAX_CHARS) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1192
+ const fileText = await fsService.readText(target)
1193
+ if (fileText.length + newText.length > DIFF_MAX_CHARS) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1194
+ // 行尾处理:磁盘文件可能是 CRLF/CR 而工具参数为 LF。优先按原始文本字面匹配
1195
+ // (预览与实际 edit 结果一致);字面匹配失败且磁盘含 CR/CRLF 时,退而按 \n
1196
+ // 归一化匹配构建预览窗口(与 splitDiffLines 同一归一化规则),并在 payload 上
1197
+ // 标记 eolNormalized——此时预览仅为意图展示:实际 edit 按原始字节字面匹配
1198
+ // 仍可能失败,由客户端提示,避免审批者基于"假成功"预览做决策。
1199
+ const rawIdx = oldText ? fileText.indexOf(oldText) : -1
1200
+ // oldNorm/newNorm 为补丁级小字符串,供行数统计与归一化预览共用;fileNorm
1201
+ // 为全文件副本,仅在字面匹配失败且文件确实含 \r 时才构建(避免常见路径对
1202
+ // 最多 1MB 文件做两趟全量 replace 扫描)。
1203
+ const oldNorm = normEol(oldText)
1204
+ const newNorm = normEol(newText)
1205
+ let eolNormalized = false
1206
+ let idx = rawIdx
1207
+ let baseText = fileText
1208
+ let oldLen = oldText.length
1209
+ if (rawIdx === -1 && oldNorm && fileText.indexOf('\r') !== -1) {
1210
+ const fileNorm = normEol(fileText)
1211
+ const normIdx = fileNorm.indexOf(oldNorm)
1212
+ if (normIdx !== -1) {
1213
+ idx = normIdx
1214
+ baseText = fileNorm
1215
+ oldLen = oldNorm.length
1216
+ eolNormalized = true
1217
+ }
1218
+ }
1219
+ if (idx === -1) {
1220
+ // 磁盘内容已与提案脱节(旧文本未找到):退回补丁级对比,至少展示改动意图
1221
+ return diffPayloadOrFallback(fp, oldText, newText, 'modified')
1222
+ }
1223
+ const applied = baseText.slice(0, idx) + (eolNormalized ? newNorm : newText) + baseText.slice(idx + oldLen)
1224
+ const W = 200
1225
+ const oldLines = splitDiffLines(baseText)
1226
+ const newLines = splitDiffLines(applied)
1227
+ const lineStart = baseText.slice(0, idx).split('\n').length
1228
+ const oldCnt = splitDiffLines(oldNorm).length
1229
+ const newCnt = splitDiffLines(newNorm).length
1230
+ const winStart = Math.max(1, lineStart - W)
1231
+ const winOldEnd = Math.min(oldLines.length, lineStart + oldCnt - 1 + W)
1232
+ const winNewEnd = Math.min(newLines.length, lineStart + newCnt - 1 + W)
1233
+ const winOldText = oldLines.slice(winStart - 1, winOldEnd).join('\n')
1234
+ const winNewText = newLines.slice(winStart - 1, winNewEnd).join('\n')
1235
+ const payload = diffPayloadOrFallback(fp, winOldText, winNewText, 'modified', winStart)
1236
+ // diffPayloadOrFallback 恒返回 ok:true 的 payload(失败时返回 fallback 视图而非 ok:false)
1237
+ if (eolNormalized) payload.eolNormalized = true
1238
+ return payload
1239
+ } catch (e) {
1240
+ const emsg = (e && e.message ? e.message : String(e))
1241
+ return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
1242
+ }
1243
+ }
1244
+ const content = typeof args.content === 'string' ? args.content : ''
1245
+ if (!content || content.length > DIFF_MAX_CHARS) return { ok: false, error: bi('内容缺失或过大', 'Content missing or too large') }
1246
+ try {
1247
+ const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
1248
+ const info = await fsService.stat(target)
1249
+ if (info === undefined) return newFilePayload(fp, content)
1250
+ if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
1251
+ if (info.size !== undefined && info.size + content.length > DIFF_MAX_CHARS) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1252
+ const oldText = await fsService.readText(target)
1253
+ if (oldText.length + content.length > DIFF_MAX_CHARS) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1254
+ return diffPayloadOrFallback(fp, oldText, content, 'modified')
1255
+ } catch (e) {
1256
+ // 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
1257
+ // 返回双语对象,由路由侧 L(r.error, lang) 按语言取值。
1258
+ const emsg = (e && e.message ? e.message : String(e))
1259
+ return { ok: false, error: { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg } }
1260
+ }
1261
+ }
1262
+ return null
1263
+ }
1264
+
1265
+ function commandArg(args) {
1266
+ try { return args && typeof args === 'object' && typeof args.command === 'string' ? args.command : '' } catch (e) { return '' }
1267
+ }
1268
+
1269
+ function isOutside(p, rootKey) {
1270
+ const r = norm(rootKey)
1271
+ if (!r) return false
1272
+ const s = norm(p)
1273
+ const abs = (s.indexOf('/') === 0 || /^[a-zA-Z]:/.test(s)) ? s : r + '/' + s
1274
+ return abs.toLowerCase().indexOf(r.toLowerCase()) !== 0
1275
+ }
1276
+
1277
+ function callKey(name, args) {
1278
+ return name + '\u0000' + safeJson(args)
1279
+ }
1280
+
1281
+ function repeatStreak(name, args) {
1282
+ const key = callKey(name, args)
1283
+ let n = 0
1284
+ for (let i = recent.length - 1; i >= 0; i--) {
1285
+ if (recent[i] === key) n++
1286
+ else break
1287
+ }
1288
+ return n
1289
+ }
1290
+
1291
+ function quickAction(name) {
1292
+ const proj = projectBlock()
1293
+ const pMap = proj && proj.quickTools ? proj.quickTools : {}
1294
+ for (const k of Object.keys(pMap)) {
1295
+ if (pMap[k] !== 'inherit' && matchGlob(k, name)) return { action: pMap[k] }
1296
+ }
1297
+ const gMap = config.global.quickTools || {}
1298
+ for (const k of Object.keys(gMap)) {
1299
+ if (matchGlob(k, name)) return { action: gMap[k] }
1300
+ }
1301
+ return null
1302
+ }
1303
+
1304
+ function textOfBlock(b) {
1305
+ if (!b) return ''
1306
+ if (typeof b === 'string') return b
1307
+ if (b.type === 'text' && typeof b.text === 'string') return b.text
1308
+ if (typeof b.text === 'string') return b.text
1309
+ if (typeof b.content === 'string') return b.content
1310
+ return ''
1311
+ }
1312
+
1313
+ function recentUserText(exec) {
1314
+ try {
1315
+ const agent = (exec && exec.agent) || agentRef
1316
+ const session = agent && agent.session
1317
+ if (!session || typeof session.deriveMessages !== 'function') return ''
1318
+ const msgs = session.deriveMessages()
1319
+ for (let i = msgs.length - 1; i >= 0; i--) {
1320
+ const m = msgs[i]
1321
+ if (!m || m.role !== 'user') continue
1322
+ const src = m.source
1323
+ if (src && (src.kind === 'tool' || src.kind === 'plugin')) continue
1324
+ let text = ''
1325
+ const c = m.content
1326
+ if (typeof c === 'string') text = c
1327
+ else if (Array.isArray(c)) {
1328
+ for (const b of c) text += textOfBlock(b)
1329
+ }
1330
+ text = String(text).trim()
1331
+ if (text) return text.length > 200 ? text.slice(0, 200) + '…' : text
1332
+ }
1333
+ return ''
1334
+ } catch (e) { return '' }
1335
+ }
1336
+
1337
+ function argDescription(args) {
1338
+ try {
1339
+ if (!args || typeof args !== 'object') return ''
1340
+ const d = args.description
1341
+ if (typeof d === 'string' && d.trim()) return d.trim()
1342
+ return ''
1343
+ } catch (e) { return '' }
1344
+ }
1345
+
1346
+ function describeIntent(exec, d) {
1347
+ const t = exec.name
1348
+ const v = d.value !== undefined && d.value !== null ? String(d.value) : ''
1349
+ if (d.kind === 'command' && v) {
1350
+ const first = String(v).split(/[;|]/)[0].trim()
1351
+ const f = first.length > 80 ? first.slice(0, 80) + '…' : first
1352
+ return bi('执行命令 ' + f, 'Run command ' + f)
1353
+ }
1354
+ if (d.kind === 'path' && v) {
1355
+ if (d.cat === 'read') return bi('读取文件 ' + v, 'Read file ' + v)
1356
+ if (d.cat === 'edit') return bi('写入/修改文件 ' + v, 'Write/modify file ' + v)
1357
+ return bi('访问路径 ' + v, 'Access path ' + v)
1358
+ }
1359
+ if (d.cat === 'doomloop') return bi('重复操作拦截:' + t + ' 连续多次相同调用,疑似循环', 'Doom Loop: ' + t + ' repeated identically, possible loop')
1360
+ if (d.cat === 'subagent') return bi('启动子代理(' + t + ')', 'Spawn subagent (' + t + ')')
1361
+ if (d.cat === 'quick') return bi('调用快捷工具 ' + t, 'Call quick tool ' + t)
1362
+ if (d.cat === 'custom') return bi('命中自定义规则,调用 ' + t, 'Custom rule matched, calling ' + t)
1363
+ return bi('调用 ' + t, 'Calling ' + t)
1364
+ }
1365
+
1366
+ function baseName(p) {
1367
+ const s = String(p || '').replace(/\\/g, '/').replace(/\/+$/, '')
1368
+ const idx = s.lastIndexOf('/')
1369
+ return idx >= 0 ? s.slice(idx + 1) : s
1370
+ }
1371
+
1372
+ function humanArgsPreview(name, args) {
1373
+ const lang = uiLang
1374
+ const lines = []
1375
+ const push = (label, value, extra) => {
1376
+ if (value === undefined || value === null) return
1377
+ const s = String(value)
1378
+ if (!s) return
1379
+ const e = { label, value: s.length > 200 ? s.slice(0, 200) + '…' : s }
1380
+ if (extra) { for (const k of Object.keys(extra)) e[k] = extra[k] }
1381
+ lines.push(e)
1382
+ }
1383
+ const t = (zh, en) => (lang === 'en' ? en : zh)
1384
+ try {
1385
+ if (!args || typeof args !== 'object') return lines
1386
+ const fp = typeof args.file_path === 'string' ? args.file_path : null
1387
+ if (FILE_READ_TOOLS[name]) {
1388
+ const target = fp || args.path || ''
1389
+ // 图片无法按文本预览,路径不做可点击(其余 read 可点击打开内容预览)
1390
+ const clickable = name === 'read_image' ? undefined : { path: fp }
1391
+ push(name === 'read_image' ? t('读取图片', 'Read image') : t('读取', 'Read'), target ? baseName(target) : '', fp ? clickable : undefined)
1392
+ if (fp) push(t('路径', 'Path'), fp, clickable)
1393
+ if (args.offset !== undefined) push(t('偏移', 'Offset'), args.offset)
1394
+ if (args.limit !== undefined) push(t('行数', 'Lines'), args.limit)
1395
+ } else if (FILE_WRITE_TOOLS[name]) {
1396
+ push(name === 'edit' ? t('修改', 'Edit') : t('写入', 'Write'), fp ? baseName(fp) : '', fp ? { path: fp } : undefined)
1397
+ if (fp) push(t('路径', 'Path'), fp, { path: fp })
1398
+ const content = typeof args.content === 'string' ? args.content : (typeof args.new_string === 'string' ? args.new_string : '')
1399
+ if (content) push(t('内容', 'Content'), content.length > 140 ? content.slice(0, 140) + '…(共 ' + content.length + ' 字符)' : content)
1400
+ } else if (COMMAND_TOOLS[name]) {
1401
+ push(t('命令', 'Command'), args.command || '')
1402
+ if (typeof args.description === 'string' && args.description) push(t('说明', 'Description'), args.description)
1403
+ } else if (name === 'web_search' || name === 'web_fetch') {
1404
+ if (typeof args.query === 'string') push(t('查询', 'Query'), args.query)
1405
+ if (typeof args.url === 'string') push('URL', args.url)
1406
+ } else {
1407
+ if (fp) push(t('路径', 'Path'), fp)
1408
+ if (typeof args.description === 'string' && args.description) push(t('说明', 'Description'), args.description)
1409
+ }
1410
+ } catch (e) {}
1411
+ return lines
1412
+ }
1413
+
1414
+ function decide(exec) {
1415
+ const name = exec.name
1416
+ const args = exec.arguments
1417
+ // deny 例外可携带自定义拒绝原因;有则用自定义文案,无则回退「(例外 id)」标注
1418
+ const exReason = (d) => {
1419
+ if (d && d.action === 'deny' && d.reason) return d.reason
1420
+ if (d && d.ruleId) return '(例外 ' + d.ruleId + ')'
1421
+ return ''
1422
+ }
1423
+ const exReasonEn = (d) => {
1424
+ if (d && d.action === 'deny' && d.reason) return d.reason
1425
+ if (d && d.ruleId) return ' (exception ' + d.ruleId + ')'
1426
+ return ''
1427
+ }
1428
+ if (typeof name === 'string' && name.indexOf('perm_') === 0) {
1429
+ return { action: 'allow', reason: bi('permgate 自身管理工具,始终放行', 'permgate management tool, always allowed'), cat: null, value: null, kind: null }
1430
+ }
1431
+ if (sessionPresetName(exec) !== 'custom-review') {
1432
+ return { action: 'allow', reason: bi('会话未选择「自定义审查」,由 DSH 权限预设处理', 'Session has not selected "Custom Review"; handled by DSH permission presets'), cat: null, value: null, kind: null }
1433
+ }
1434
+ if (repeatStreak(name, args) >= REPEAT_STREAK) {
1435
+ const d = resolveCategory('doomloop', null, null)
1436
+ if (d.action !== 'allow') {
1437
+ return { action: d.action, reason: bi('重复操作(Doom Loop):' + name + ' 已连续重复 ' + (REPEAT_STREAK + 1) + ' 次相同调用', 'Doom Loop: ' + name + ' repeated ' + (REPEAT_STREAK + 1) + ' identical calls'), ruleId: d.ruleId, cat: 'doomloop', value: null, kind: null }
1438
+ }
1439
+ }
1440
+ const proj = projectBlock()
1441
+ const rules = []
1442
+ if (proj && Array.isArray(proj.custom)) { for (const r of proj.custom) rules.push(r) }
1443
+ if (Array.isArray(config.global.custom)) { for (const r of config.global.custom) rules.push(r) }
1444
+ for (const rule of rules) {
1445
+ if (ruleMatches(rule, name, args)) {
1446
+ return { action: rule.action, ruleId: rule.id, reason: rule.reason || bi('自定义规则 ' + rule.id + ' 命中', 'Custom rule ' + rule.id + ' matched'), cat: 'custom', value: null, kind: 'rule' }
1447
+ }
1448
+ }
1449
+ if (FILE_READ_TOOLS[name]) {
1450
+ const fp = pathArg(args)
1451
+ if (fp && isOutside(fp, root)) {
1452
+ const d = resolveCategory('directory', fp, 'path')
1453
+ const exZh = exReason(d)
1454
+ const exEn = exReasonEn(d)
1455
+ return { action: d.action, reason: bi('目录权限:访问工作区外 ' + fp + exZh, 'Directory permission: access outside workspace ' + fp + exEn), ruleId: d.ruleId, cat: 'directory', value: fp, kind: 'path' }
1456
+ }
1457
+ const d = resolveCategory('read', fp, 'path')
1458
+ const exZh = exReason(d)
1459
+ const exEn = exReasonEn(d)
1460
+ return { action: d.action, reason: bi('读取权限' + (fp ? ':' + fp : '') + exZh, 'Read permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'read', value: fp, kind: 'path' }
1461
+ }
1462
+ if (FILE_WRITE_TOOLS[name]) {
1463
+ const fp = pathArg(args)
1464
+ if (fp && isOutside(fp, root)) {
1465
+ // 双重审查:工作区外写入先过「目录访问」闸(能否触碰),directory 不拒绝时再过「编辑」闸。
1466
+ // 合并矩阵:任一 deny → 拒绝;否则任一 ask → 弹窗一次;否则放行。
1467
+ const d = resolveCategory('directory', fp, 'path')
1468
+ const e = resolveCategory('edit', fp, 'path')
1469
+ if (d.action === 'deny' || e.action === 'deny') {
1470
+ const src = d.action === 'deny' ? d : e
1471
+ return { action: 'deny', reason: bi('目录权限:拒绝写入工作区外 ' + fp + exReason(src), 'Directory permission: write to outside workspace denied ' + fp + exReasonEn(src)), ruleId: src.ruleId, cat: 'directory', value: fp, kind: 'path' }
1472
+ }
1473
+ if (d.action === 'ask' || e.action === 'ask') {
1474
+ return { action: 'ask', reason: bi('目录权限:访问工作区外 ' + fp + '(写入需确认)', 'Directory permission: access outside workspace ' + fp + ' (write requires confirmation)'), ruleId: null, cat: 'directory', value: fp, kind: 'path' }
1475
+ }
1476
+ const src = d.ruleId ? d : (e.ruleId ? e : null)
1477
+ return { action: 'allow', reason: bi('目录权限:访问工作区外 ' + fp + (src ? exReason(src) : ''), 'Directory permission: access outside workspace ' + fp + (src ? exReasonEn(src) : '')), ruleId: src ? src.ruleId : null, cat: 'directory', value: fp, kind: 'path' }
1478
+ }
1479
+ const d = resolveCategory('edit', fp, 'path')
1480
+ const exZh = exReason(d)
1481
+ const exEn = exReasonEn(d)
1482
+ return { action: d.action, reason: bi('编辑权限' + (fp ? ':' + fp : '') + exZh, 'Edit permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'edit', value: fp, kind: 'path' }
1483
+ }
1484
+ if (COMMAND_TOOLS[name]) {
1485
+ const cmd = commandArg(args)
1486
+ // 命令的所有可识别命令 token 均已命中 allow 例外 → 视为已覆盖,直接放行(不再弹窗)
1487
+ if (commandFullyCovered(cmd)) {
1488
+ return { action: 'allow', reason: bi('命令组成均已命中例外,放行', 'All command tokens covered by exceptions, allowed'), cat: null, value: null, kind: null }
1489
+ }
1490
+ const d = resolveCategory('command', cmd, 'command')
1491
+ const exZh = exReason(d)
1492
+ const exEn = exReasonEn(d)
1493
+ return { action: d.action, reason: bi('执行命令' + exZh, 'Run command' + exEn), ruleId: d.ruleId, cat: 'command', value: cmd, kind: 'command' }
1494
+ }
1495
+ if (SUBAGENT_TOOLS[name]) {
1496
+ const d = resolveCategory('subagent', null, null)
1497
+ return { action: d.action, reason: bi('启动子代理' + exReason(d), 'Spawn subagent' + exReasonEn(d)), ruleId: d.ruleId, cat: 'subagent', value: null, kind: null }
1498
+ }
1499
+ const q = quickAction(name)
1500
+ if (q) return { action: q.action, reason: bi('快捷设置:' + name + ' → ' + q.action, 'Quick setting: ' + name + ' → ' + q.action), ruleId: null, cat: 'quick', value: name, kind: 'tool' }
1501
+ return { action: 'allow', reason: bi('未匹配任何规则,放行', 'No rule matched, allowed'), cat: null, value: null, kind: null }
1502
+ }
1503
+
1504
+ function recordDecision(d, exec) {
1505
+ decisions.push({ ts: new Date().toISOString(), tool: exec.name, action: d.action, ruleId: d.ruleId || null, reason: d.reason || '' })
1506
+ if (decisions.length > MAX_DECISIONS) decisions.splice(0, decisions.length - MAX_DECISIONS)
1507
+ }
1508
+
1509
+ function dirGlob(p) {
1510
+ const s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
1511
+ const idx = s.lastIndexOf('/')
1512
+ let dir = idx >= 0 ? s.slice(0, idx) : s
1513
+ if (/^[a-zA-Z]:$/.test(dir)) dir += '/'
1514
+ if (!dir) dir = '/'
1515
+ return dir + '/*'
1516
+ }
1517
+
1518
+ function alreadyInProject(value, kind, catKey) {
1519
+ const proj = projectBlock()
1520
+ if (!proj) return false
1521
+ if (kind === 'command') {
1522
+ const cat = proj.command
1523
+ return !!(cat && Array.isArray(cat.exceptions) && cat.exceptions.some((r) => r.match === value))
1524
+ }
1525
+ if (kind === 'path' && catKey && EXC_CATS.indexOf(catKey) !== -1) {
1526
+ const cat = proj[catKey]
1527
+ return !!(cat && Array.isArray(cat.exceptions) && cat.exceptions.some((r) => r.path === value))
1528
+ }
1529
+ if (kind === 'tool') {
1530
+ return !!(Array.isArray(proj.custom) && proj.custom.some((r) => r.tool === value))
1531
+ }
1532
+ return false
1533
+ }
1534
+
1535
+ // 路由器命令的「带值选项」:跳过选项本身后还要跳过它的值(git -c key=val / npm --prefix ./x)
1536
+ const ROUTER_OPT_VALUE = { '-c': 1, '-C': 1, '--config': 1, '--config-env': 1, '--git-dir': 1, '--work-tree': 1, '--namespace': 1, '--exec-path': 1, '-H': 1, '--prefix': 1, '--cwd': 1, '--project': 1, '--registry': 1 }
1537
+
1538
+ function commandTokens(seg) {
1539
+ const toks = String(seg).trim().split(/\s+/)
1540
+ if (!toks.length) return []
1541
+ let idx = 0
1542
+ if (toks[0].indexOf('$') === 0) {
1543
+ if (toks[1] === '=') idx = 2
1544
+ else return []
1545
+ }
1546
+ const cleanToken = (t) => {
1547
+ t = String(t).replace(/[;|]$/, '')
1548
+ t = t.replace(/^[.\/\\]/, '').trim()
1549
+ if (!t) return ''
1550
+ if (t.indexOf('$') !== -1 || t.indexOf('@') !== -1) return ''
1551
+ if (!/^[A-Za-z_]/.test(t)) return ''
1552
+ if (/[()[\]{}'"]/.test(t)) return ''
1553
+ return t
1554
+ }
1555
+ const first = cleanToken(toks[idx])
1556
+ if (!first) return []
1557
+ if (PS_KEYWORDS[first.toLowerCase()]) {
1558
+ // 关键字开头(foreach/if…):继续向后找真正的命令 token
1559
+ for (let j = idx + 1; j < toks.length; j++) {
1560
+ const t = cleanToken(toks[j])
1561
+ if (!t) continue
1562
+ if (PS_KEYWORDS[t.toLowerCase()]) continue
1563
+ return [t]
1564
+ }
1565
+ return []
1566
+ }
1567
+ if (!ROUTER_CMDS[first.toLowerCase()]) return [first]
1568
+ // 路由器命令:跳过选项(含带值选项的值),取第一个非选项 token 作子命令
1569
+ // 子命令位不做 PS 关键字过滤(git switch 是真子命令;子命令属于路由器自己的词汇表)
1570
+ let sub = ''
1571
+ for (let j = idx + 1; j < toks.length; j++) {
1572
+ const raw = String(toks[j]).replace(/[;|]$/, '')
1573
+ if (raw.indexOf('-') === 0) {
1574
+ if (ROUTER_OPT_VALUE[raw]) j++
1575
+ continue
1576
+ }
1577
+ const t = cleanToken(raw)
1578
+ if (!t) continue
1579
+ sub = t
1580
+ break
1581
+ }
1582
+ return sub ? [first, sub] : [first]
1583
+ }
1584
+
1585
+ // 命令的所有可识别命令 token(如 Get-ChildItem / git status)是否均已命中 allow 例外。
1586
+ // 两道安全闸:① 破坏性命令(杀进程/删文件/改系统)一律不走「全命中」快速通道,
1587
+ // ② 任一命令段识别不出命令 token(变量赋值/表达式/字符串拼接等)也不视为已覆盖。
1588
+ // 两者命中时仍需弹窗走正常判定(用户显式配置的 allow 例外仍会命中,这里只挡「碰巧覆盖」)。
1589
+ const DANGEROUS_CMD_RE = /\b(?:Stop-Process|Stop-Service|Stop-Computer|Stop-Job|Restart-Computer|Restart-Service|Remove-Item|Remove-ItemProperty|Remove-Service|Remove-PSDrive|Remove-Variable|Remove-Alias|Remove-Event|Remove-Job|Start-Process|Start-Service|Start-Computer|Start-Job|taskkill|shutdown|format|diskpart|rmdir|erase|Clear-Content|Clear-Item|Set-ExecutionPolicy|icacls|takeown|attrib|reg\s+delete|wmic\s+process)\b/i
1590
+ function commandFullyCovered(cmd) {
1591
+ try {
1592
+ const whole = String(cmd || '')
1593
+ // 破坏性命令:即便命令 token 命中 allow 例外,也不允许静默放行
1594
+ if (DANGEROUS_CMD_RE.test(whole)) return false
1595
+ const parts = whole.split(/[|;]/)
1596
+ const results = []
1597
+ for (const seg of parts) {
1598
+ const toks = commandTokens(seg)
1599
+ // 识别不出命令 token 的段:解析器看不懂,不能当作「已覆盖」
1600
+ if (!toks.length) return false
1601
+ let label = toks[0]
1602
+ if (toks.length >= 2 && ROUTER_CMDS[toks[0].toLowerCase()]) label = toks[0] + ' ' + toks[1]
1603
+ const value = label + ' *'
1604
+ const proj = projectBlock()
1605
+ const lists = []
1606
+ if (proj && proj.command && Array.isArray(proj.command.exceptions)) lists.push(proj.command.exceptions)
1607
+ if (config.global.command && Array.isArray(config.global.command.exceptions)) lists.push(config.global.command.exceptions)
1608
+ let hit = false
1609
+ for (const list of lists) {
1610
+ for (const r of list) {
1611
+ if (r.action === 'allow' && matchCommand(r.match, value)) { hit = true; break }
1612
+ }
1613
+ if (hit) break
1614
+ }
1615
+ results.push(hit)
1616
+ }
1617
+ return results.length > 0 && results.every(Boolean)
1618
+ } catch (e) { return false }
1619
+ }
1620
+
1621
+ function buildCandidates(entry) {
1622
+ const out = []
1623
+ const push = (label, value, kind) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind })
1624
+ if (entry.kind === 'command' && entry.value) {
1625
+ const parts = String(entry.value).split(/[|;]/)
1626
+ const seen = {}
1627
+ for (const seg of parts) {
1628
+ const toks = commandTokens(seg)
1629
+ if (!toks.length) continue
1630
+ let label = toks[0]
1631
+ if (toks.length >= 2 && ROUTER_CMDS[toks[0].toLowerCase()]) label = toks[0] + ' ' + toks[1]
1632
+ if (seen[label]) continue
1633
+ seen[label] = true
1634
+ const val = label + ' *'
1635
+ if (alreadyInProject(val, 'command', null)) continue
1636
+ push(label, val, 'command')
1637
+ }
1638
+ } else if (entry.kind === 'path' && entry.value) {
1639
+ let val = entry.value
1640
+ if (entry.cat === 'directory') val = dirGlob(entry.value)
1641
+ if (!alreadyInProject(val, 'path', entry.cat)) push(val, val, 'path')
1642
+ }
1643
+ // 其余分类无「例外」候选:快捷工具(web_search/skill 等)走 quickTools 设置;
1644
+ // 子代理/重复操作只有模式默认值 —— 均不生成候选
1645
+ return out
1646
+ }
1647
+
1648
+ function askUser(exec, d) {
1649
+ return new Promise((resolve) => {
1650
+ let settled = false
1651
+ let onAbort = null
1652
+ const id = 'p' + Math.random().toString(36).slice(2, 10)
1653
+ const argsJson = safeJson(exec.arguments)
1654
+ const argsPreview = argsJson && argsJson.length > 160 ? argsJson.slice(0, 160) + '…' : (argsJson || '')
1655
+ const taskText = argDescription(exec.arguments) || recentUserText(exec)
1656
+ const entry = {
1657
+ id,
1658
+ tool: exec.name,
1659
+ argsJson,
1660
+ cat: d.cat || null,
1661
+ value: d.value !== undefined && d.value !== null ? String(d.value) : null,
1662
+ kind: d.kind || null,
1663
+ reason: d.reason || bi('', ''),
1664
+ intent: taskText || describeIntent(exec, d),
1665
+ ts: Date.now(),
1666
+ candidates: [],
1667
+ argLines: humanArgsPreview(exec.name, exec.arguments),
1668
+ // 审批发起时的项目根:root 是跨会话共享的闭包变量,随后可能被其他会话覆盖,
1669
+ // 打相对路径/对比/打开文件必须用发起会话自己的根
1670
+ projRoot: root || null,
1671
+ // 编辑/写入且有文件路径 → 弹窗「详情」默认展开、按需取对比数据
1672
+ hasDiff: !!FILE_WRITE_TOOLS[exec.name] && !!pathArg(exec.arguments),
1673
+ resolve,
1674
+ cleanup() {
1675
+ if (onAbort && exec.signal) { try { exec.signal.removeEventListener('abort', onAbort) } catch (e) {} }
1676
+ pendingApprovals.delete(id)
1677
+ broadcast({ type: 'pending' })
1678
+ },
1679
+ }
1680
+ entry.candidates = buildCandidates(entry)
1681
+ pendingApprovals.set(id, entry)
1682
+ broadcast({ type: 'pending' })
1683
+ // 永不超时:审批完全由用户在弹窗中决定,不会自动拒绝。
1684
+ // 唯一结束路径:用户允许/拒绝,或执行被取消(abort,见下)。
1685
+ onAbort = () => {
1686
+ if (settled) return
1687
+ settled = true
1688
+ entry.cleanup()
1689
+ resolve({ kind: 'deny', reason: uiLang === 'en' ? 'Approval request cancelled' : '审批请求已取消' })
1690
+ }
1691
+ if (exec.signal && exec.signal.addEventListener) {
1692
+ try { exec.signal.addEventListener('abort', onAbort, { once: true }) } catch (e) {}
1693
+ }
1694
+ })
1695
+ }
1696
+
1697
+ function addProjectRule(entry, kind, value, decision) {
1698
+ try {
1699
+ const block = ensureProject()
1700
+ if (kind === 'path' && entry.cat && EXC_CATS.indexOf(entry.cat) !== -1) {
1701
+ const cat = block[entry.cat] || freshCategory(entry.cat, true)
1702
+ if (!cat.exceptions) cat.exceptions = []
1703
+ const idx = cat.exceptions.findIndex((r) => r.path === value)
1704
+ if (idx !== -1) { cat.exceptions[idx].action = decision; block[entry.cat] = cat; return }
1705
+ cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision, path: value })
1706
+ block[entry.cat] = cat
1707
+ return
1708
+ }
1709
+ if (kind === 'command') {
1710
+ const cat = block.command || freshCategory('command', true)
1711
+ if (!cat.exceptions) cat.exceptions = []
1712
+ const idx = cat.exceptions.findIndex((r) => r.match === value)
1713
+ if (idx !== -1) { cat.exceptions[idx].action = decision; block.command = cat; return }
1714
+ cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision, match: value })
1715
+ block.command = cat
1716
+ return
1717
+ }
1718
+ if (!block.custom) block.custom = []
1719
+ const idx = block.custom.findIndex((r) => r.tool === value)
1720
+ if (idx !== -1) { block.custom[idx].action = decision; return }
1721
+ block.custom.unshift({ id: 'r' + Math.random().toString(36).slice(2, 8), action: decision, tool: value })
1722
+ } catch (e) {
1723
+ console.error('[permgate] addProjectRule error:', e)
1724
+ }
1725
+ }
1726
+
1727
+ function addRememberedRule(entry, action, target) {
1728
+ try {
1729
+ const block = target === 'project' ? ensureProject() : config.global
1730
+ if (entry.cat === 'quick') {
1731
+ if (!block.quickTools) block.quickTools = {}
1732
+ block.quickTools[entry.tool] = action
1733
+ return
1734
+ }
1735
+ if (entry.kind === 'path' && entry.cat && EXC_CATS.indexOf(entry.cat) !== -1 && entry.value) {
1736
+ const cat = block[entry.cat] || freshCategory(entry.cat, target === 'project')
1737
+ if (!cat.exceptions) cat.exceptions = []
1738
+ const idx = cat.exceptions.findIndex((r) => r.path === entry.value)
1739
+ if (idx !== -1) { cat.exceptions[idx].action = action; block[entry.cat] = cat; return }
1740
+ cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, path: entry.value })
1741
+ block[entry.cat] = cat
1742
+ return
1743
+ }
1744
+ if (entry.kind === 'command' && entry.cat === 'command' && entry.value) {
1745
+ const cat = block.command || freshCategory('command', target === 'project')
1746
+ if (!cat.exceptions) cat.exceptions = []
1747
+ const idx = cat.exceptions.findIndex((r) => r.match === entry.value)
1748
+ if (idx !== -1) { cat.exceptions[idx].action = action; block.command = cat; return }
1749
+ cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, match: entry.value })
1750
+ block.command = cat
1751
+ return
1752
+ }
1753
+ if (!block.custom) block.custom = []
1754
+ const idx = block.custom.findIndex((r) => r.tool === entry.tool)
1755
+ if (idx !== -1) { block.custom[idx].action = action; return }
1756
+ const rule = { id: 'r' + Math.random().toString(36).slice(2, 8), action, tool: entry.tool }
1757
+ if (entry.cat === 'doomloop' && entry.argsJson) rule.args = entry.argsJson
1758
+ block.custom.unshift(rule)
1759
+ } catch (e) {
1760
+ console.error('[permgate] addRememberedRule error:', e)
1761
+ }
1762
+ }
1763
+
1764
+ // 最近一次成功读取/写入的磁盘原文:persist 前与磁盘比对,防止覆盖外部手工编辑
1765
+ let lastDiskJson = null
1766
+
1767
+ async function persist(exec) {
1768
+ try {
1769
+ const t = await ensureTarget(exec)
1770
+ await ensureConfigDir()
1771
+ // 防覆盖守卫:配置在加载后被外部修改(手工编辑、其他实例写入)时拒绝保存,
1772
+ // 避免静默覆盖用户规则;点击「重新加载配置文件」后守卫自动放行。
1773
+ try {
1774
+ const cur = await fs.readText(t)
1775
+ if (lastDiskJson !== null && String(cur || '').trim() !== lastDiskJson) {
1776
+ saveError = uiLang === 'en' ? 'Config file changed on disk; save cancelled. Click "Reload config file" first.' : '配置文件已被外部修改,已取消保存;请先点击「重新加载配置文件」'
1777
+ broadcast({ type: 'status' })
1778
+ return false
1779
+ }
1780
+ } catch (e) {}
1781
+ const writePolicy = { mode: 'danger-full-access', workspaceRoot: root }
1782
+ await fs.writeText(t, JSON.stringify(config, null, 2), undefined, undefined, writePolicy)
1783
+ lastDiskJson = JSON.stringify(config, null, 2)
1784
+ saveError = null
1785
+ broadcast({ type: 'status' })
1786
+ return true
1787
+ } catch (e) {
1788
+ saveError = (e && e.message) ? e.message : String(e)
1789
+ return false
1790
+ }
1791
+ }
1792
+
1793
+ async function load(exec) {
1794
+ try {
1795
+ const t = await ensureTarget(exec)
1796
+ let text = null
1797
+ let missing = false
1798
+ try {
1799
+ const info = await fs.stat(t)
1800
+ missing = !info
1801
+ } catch (e) { missing = true }
1802
+ if (!missing) {
1803
+ try { text = await fs.readText(t) } catch (e) { text = null }
1804
+ }
1805
+ if (missing || text === null) {
1806
+ // 文件不存在 → 首次运行,落盘默认配置;
1807
+ // 存在但读取失败 → 保留内存配置并提示,绝不静默覆盖磁盘(避免误删规则)
1808
+ if (missing) {
1809
+ loadError = null
1810
+ config = freshConfig()
1811
+ await persist(exec)
1812
+ } else {
1813
+ loadError = uiLang === 'en' ? 'Cannot read config file: ' + t : '无法读取配置文件: ' + t
1814
+ }
1815
+ return
1816
+ }
1817
+ const parsed = JSON.parse(text)
1818
+ if (!parsed || typeof parsed !== 'object') throw new Error('根节点必须是对象')
1819
+ const isOld = parsed.global && typeof parsed.global === 'object' && parsed.global.mode !== undefined && parsed.global.directory === undefined
1820
+ config = isOld ? migrateOld(parsed) : buildConfig(parsed)
1821
+ lastDiskJson = String(text).trim()
1822
+ loadError = null
1823
+ if (isOld) await persist(exec)
1824
+ broadcast({ type: 'status' })
1825
+ } catch (e) {
1826
+ loadError = '配置解析失败: ' + ((e && e.message) || String(e))
1827
+ }
1828
+ }
1829
+
1830
+ function statusView(exec, lang) {
1831
+ const l = normLang(lang || uiLang)
1832
+ const proj = projectBlock()
1833
+ const effective = {}
1834
+ for (const c of CATS) {
1835
+ effective[c] = (proj && proj[c] && proj[c].mode && proj[c].mode !== 'inherit') ? proj[c].mode : (config.global[c] ? config.global[c].mode : 'allow')
1836
+ }
1837
+ const stats = { deny: 0, ask: 0 }
1838
+ for (const d of decisions) {
1839
+ if (d.action === 'deny') stats.deny++
1840
+ else if (d.action === 'ask') stats.ask++
1841
+ }
1842
+ return {
1843
+ configPath: target ? (fs.processPath ? fs.processPath(target) : String(root)) : String(root) + '/.dsh/.permgate.json',
1844
+ active: true,
1845
+ preset: sessionPresetName(exec),
1846
+ sandbox: {
1847
+ global: config.global.sandboxMode || 'danger-full-access',
1848
+ project: (projectBlock() && projectBlock().sandboxMode) || 'inherit',
1849
+ effective: effectiveSandboxConfig(),
1850
+ },
1851
+ activeForSession: sessionPresetName(exec) === 'custom-review',
1852
+ projectKey: root,
1853
+ rootSource,
1854
+ debugAgentCwd: agentCwd(exec) || null,
1855
+ loadError,
1856
+ saveError,
1857
+ categories: {
1858
+ global: config.global,
1859
+ project: proj || null,
1860
+ },
1861
+ effective,
1862
+ quickTools: {
1863
+ global: config.global.quickTools || {},
1864
+ project: (proj && proj.quickTools) || {},
1865
+ },
1866
+ custom: {
1867
+ global: config.global.custom || [],
1868
+ project: (proj && proj.custom) || [],
1869
+ },
1870
+ counts: {
1871
+ globalCustom: (config.global.custom || []).length,
1872
+ projectCustom: (proj && proj.custom ? proj.custom : []).length,
1873
+ },
1874
+ stats,
1875
+ recentDecisions: decisions.slice(-10).map((d) => Object.assign({}, d, { reason: typeof d.reason === 'string' ? d.reason : L(d.reason, l) })),
1876
+ cats: CATS,
1877
+ excCats: EXC_CATS,
1878
+ modes: MODES,
1879
+ allModes: ALL_MODES,
1880
+ }
1881
+ }
1882
+
1883
+ // ── HTTP 路由(浏览器 UI 经 /permgate/* 同源调用)──────────────────────────
1884
+
1885
+ function json(res, data, status) {
1886
+ const body = JSON.stringify(data)
1887
+ res.writeHead(status || 200, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
1888
+ res.end(body)
1889
+ }
1890
+
1891
+ function readBody(req) {
1892
+ return new Promise((resolve, reject) => {
1893
+ const chunks = []
1894
+ req.on('data', (c) => { chunks.push(c) })
1895
+ req.on('end', () => {
1896
+ try {
1897
+ const text = Buffer.concat(chunks).toString('utf8').trim()
1898
+ resolve(text ? JSON.parse(text) : {})
1899
+ } catch (e) { reject(e) }
1900
+ })
1901
+ req.on('error', reject)
1902
+ })
1903
+ }
1904
+
1905
+ // ── SSE:/permgate/events 长连接推送(状态/待审批变化即时通知浏览器)────────
1906
+
1907
+ function broadcast(payload) {
1908
+ const data = 'data: ' + JSON.stringify(payload) + '\n\n'
1909
+ for (const res of sseClients) {
1910
+ try { res.write(data) } catch (e) { sseClients.delete(res) }
1911
+ }
1912
+ }
1913
+
1914
+ function handleEvents(req, res) {
1915
+ res.writeHead(200, {
1916
+ 'content-type': 'text/event-stream; charset=utf-8',
1917
+ 'cache-control': 'no-cache',
1918
+ connection: 'keep-alive',
1919
+ 'x-accel-buffering': 'no',
1920
+ })
1921
+ res.write(': connected\n\n')
1922
+ sseClients.add(res)
1923
+ const done = () => { sseClients.delete(res) }
1924
+ req.on('close', done)
1925
+ res.on('close', done)
1926
+ res.on('error', done)
1927
+ }
1928
+
1929
+ async function routePermgate(req, res) {
1930
+ try {
1931
+ let pathname = '/permgate'
1932
+ let search = null
1933
+ try {
1934
+ const u = new URL(req.url || '/permgate', 'http://localhost')
1935
+ pathname = u.pathname.replace(/\/+$/, '') || '/permgate'
1936
+ search = u.searchParams
1937
+ } catch (e) {}
1938
+ const method = (req.method || 'GET').toUpperCase()
1939
+ if (pathname === '/permgate/events' && method === 'GET') return handleEvents(req, res)
1940
+ const a = method === 'POST' ? await readBody(req) : {}
1941
+ // 语言参数归一化:缺失/空/非法一律中文;同时更新 uiLang 供宿主即时文案
1942
+ const lang = normLang(method === 'GET' ? (search ? search.get('lang') : null) : a.lang)
1943
+ uiLang = lang
1944
+ // 按会话解析(设置面板/DockBar 传 sessionId):后续所有项目解析跟随该会话,
1945
+ // 切换会话后面板显示与写入的都是当前会话的项目配置
1946
+ let exec = null
1947
+ const sid = method === 'POST' ? a.sessionId : (search ? search.get('sessionId') : null)
1948
+ if (sid && ctx.sessions && typeof ctx.sessions.get === 'function') {
1949
+ try {
1950
+ const s = ctx.sessions.get(sid)
1951
+ if (s) exec = { agent: { session: s } }
1952
+ } catch (e) {}
1953
+ }
1954
+ if (pathname === '/permgate/pending' && method === 'GET') {
1955
+ const out = []
1956
+ for (const e of pendingApprovals.values()) {
1957
+ const argsPreview = e.argsJson && e.argsJson.length > 160 ? e.argsJson.slice(0, 160) + '…' : (e.argsJson || '')
1958
+ const reason = typeof e.reason === 'string' ? e.reason : L(e.reason, lang)
1959
+ const intent = typeof e.intent === 'string' ? e.intent : L(e.intent, lang)
1960
+ out.push({ id: e.id, tool: e.tool, reason, ts: e.ts, args: argsPreview, intent, candidates: e.candidates || [], argLines: e.argLines || [], hasDiff: e.hasDiff === true })
1961
+ }
1962
+ return json(res, out)
1963
+ }
1964
+ if (pathname === '/permgate/file-diff' && method === 'POST') {
1965
+ const entry = pendingApprovals.get(a.id)
1966
+ if (!entry) return json(res, { ok: false, error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
1967
+ try {
1968
+ const r = await buildFileDiffData(entry, fs)
1969
+ if (!r) return json(res, { ok: false, error: lang === 'en' ? 'Cannot build comparison' : '无法生成对比' })
1970
+ if (!r.ok) return json(res, { ok: false, error: typeof r.error === 'string' ? r.error : L(r.error, lang) })
1971
+ return json(res, r)
1972
+ } catch (e) {
1973
+ // 记录真实错误,避免生产故障只以泛化文案呈现而不可见
1974
+ console.error('[permgate] file-diff error:', e)
1975
+ return json(res, { ok: false, error: lang === 'en' ? 'Cannot build comparison' : '无法生成对比' })
1976
+ }
1977
+ }
1978
+ if (pathname === '/permgate/status' && method === 'GET') {
1979
+ await init(exec)
1980
+ // 按会话查询:web 端 DockBar/设置面板传 sessionId,状态只反映该会话的权限;
1981
+ // 缺失时走全局回退(最近权限事件会话 / 最后创建会话)
1982
+ return json(res, statusView(exec, lang))
1983
+ }
1984
+ if (pathname === '/permgate/decide' && method === 'POST') {
1985
+ const entry = pendingApprovals.get(a.id)
1986
+ if (!entry) return json(res, { error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
1987
+ let allow = false
1988
+ let ruleCount = 0
1989
+ if (typeof a.action === 'string' && (a.action === 'allow' || a.action === 'deny')) {
1990
+ allow = a.action === 'allow'
1991
+ if (Array.isArray(a.rules)) {
1992
+ for (const r of a.rules) {
1993
+ if (r && r.value && (r.decision === 'allow' || r.decision === 'deny')) {
1994
+ addProjectRule(entry, r.kind || null, String(r.value), r.decision)
1995
+ ruleCount++
1996
+ }
1997
+ }
1998
+ }
1999
+ } else {
2000
+ const choice = a.choice
2001
+ if (DECIDE_CHOICES.indexOf(choice) === -1) return json(res, { error: lang === 'en' ? 'Invalid choice' : '非法选择' })
2002
+ const m = /^(allow|deny)-(global|project)$/.exec(choice)
2003
+ if (m) {
2004
+ addRememberedRule(entry, m[1], m[2])
2005
+ ruleCount++
2006
+ }
2007
+ allow = choice === 'allow' || choice === 'allow-global' || choice === 'allow-project'
2008
+ }
2009
+ const customReason = typeof a.reason === 'string' ? a.reason.trim().slice(0, 500) : ''
2010
+ entry.cleanup()
2011
+ if (ruleCount > 0) await persist()
2012
+ entry.resolve(allow
2013
+ ? { kind: 'allow', ruleAdded: ruleCount > 0 }
2014
+ : { kind: 'deny', reason: customReason || (lang === 'en' ? (ruleCount > 0 ? 'User denied and rule added' : 'User denied') : (ruleCount > 0 ? '用户拒绝并加入规则' : '用户拒绝')) })
2015
+ return json(res, { ok: true, ruleAdded: ruleCount > 0 })
2016
+ }
2017
+ if (pathname === '/permgate/set-sandbox' && method === 'POST') {
2018
+ await init(exec)
2019
+ const target = a.target === 'project' ? 'project' : 'global'
2020
+ if (!setSandboxConfig(target, a.mode)) return json(res, { error: '非法沙箱参数: target=' + target + ' mode=' + a.mode })
2021
+ await persist(exec)
2022
+ syncSandbox(exec)
2023
+ return json(res, statusView(exec))
2024
+ }
2025
+ if (pathname === '/permgate/set-categories' && method === 'POST') {
2026
+ await init(exec)
2027
+ for (const t of ['global', 'project']) {
2028
+ const src = a[t]
2029
+ if (!src || typeof src !== 'object') continue
2030
+ for (const c of CATS) {
2031
+ if (typeof src[c] === 'string') setCategoryMode(t, c, src[c])
2032
+ }
2033
+ }
2034
+ await persist(exec)
2035
+ return json(res, statusView(exec))
2036
+ }
2037
+ if (pathname === '/permgate/set-category' && method === 'POST') {
2038
+ await init(exec)
2039
+ if (CATS.indexOf(a.category) === -1) return json(res, { error: '未知分类: ' + a.category })
2040
+ if (!setCategoryMode(a.target, a.category, a.mode)) return json(res, { error: '非法的 target/mode 组合' })
2041
+ await persist(exec)
2042
+ return json(res, statusView(exec))
2043
+ }
2044
+ if (pathname === '/permgate/set-quick' && method === 'POST') {
2045
+ await init(exec)
2046
+ if (!a.tool || !String(a.tool)) return json(res, { error: 'tool 不能为空' })
2047
+ if (ALL_MODES.indexOf(a.action) === -1) return json(res, { error: '非法动作' })
2048
+ const block = a.target === 'project' ? ensureProject() : config.global
2049
+ if (!block.quickTools) block.quickTools = {}
2050
+ if (a.action === 'inherit') delete block.quickTools[a.tool]
2051
+ else block.quickTools[a.tool] = a.action
2052
+ await persist(exec)
2053
+ return json(res, statusView(exec))
2054
+ }
2055
+ if (pathname === '/permgate/add-exception' && method === 'POST') {
2056
+ await init(exec)
2057
+ if (EXC_CATS.indexOf(a.category) === -1) return json(res, { error: '该分类不支持例外' })
2058
+ if (!a.match || !String(a.match)) return json(res, { error: 'match 不能为空' })
2059
+ const e = normalizeException({ id: 'e' + Math.random().toString(36).slice(2, 8), action: a.action, reason: a.reason, path: a.category === 'command' ? undefined : a.match, match: a.category === 'command' ? a.match : undefined }, a.category)
2060
+ if (!e) return json(res, { error: '非法的例外参数' })
2061
+ const block = a.target === 'project' ? ensureProject() : config.global
2062
+ if (!block[a.category]) block[a.category] = freshCategory(a.category, a.target === 'project')
2063
+ if (!block[a.category].exceptions) block[a.category].exceptions = []
2064
+ block[a.category].exceptions.push(e)
2065
+ await persist(exec)
2066
+ return json(res, { added: e, status: statusView(exec) })
2067
+ }
2068
+ if (pathname === '/permgate/remove-exception' && method === 'POST') {
2069
+ await init(exec)
2070
+ const block = a.target === 'project' ? ensureProject() : config.global
2071
+ const cat = block[a.category]
2072
+ if (!cat || !Array.isArray(cat.exceptions)) return json(res, { removed: false, reason: '例外列表不存在' })
2073
+ const idx = cat.exceptions.findIndex((r) => r.id === a.id)
2074
+ if (idx === -1) return json(res, { removed: false, reason: '未找到 id=' + a.id })
2075
+ const removed = cat.exceptions.splice(idx, 1)[0]
2076
+ await persist(exec)
2077
+ return json(res, { removed: true, exception: removed, status: statusView(exec) })
2078
+ }
2079
+ if (pathname === '/permgate/add-rule' && method === 'POST') {
2080
+ await init(exec)
2081
+ if (!a.tool && !a.path && !a.args) return json(res, { error: '至少提供 tool/path/args 之一' })
2082
+ const rule = normalizeRule({ id: 'r' + Math.random().toString(36).slice(2, 8), action: a.action, tool: a.tool, path: a.path, args: a.args, reason: a.reason })
2083
+ if (!rule) return json(res, { error: '非法的规则参数' })
2084
+ const block = a.target === 'project' ? ensureProject() : config.global
2085
+ if (!block.custom) block.custom = []
2086
+ block.custom.push(rule)
2087
+ await persist(exec)
2088
+ return json(res, { added: rule, status: statusView(exec) })
2089
+ }
2090
+ if (pathname === '/permgate/remove-rule' && method === 'POST') {
2091
+ await init(exec)
2092
+ const block = a.target === 'project' ? ensureProject() : config.global
2093
+ const list = block.custom || []
2094
+ const idx = list.findIndex((r) => r.id === a.id)
2095
+ if (idx === -1) return json(res, { removed: false, reason: '未找到 id=' + a.id })
2096
+ const removed = list.splice(idx, 1)[0]
2097
+ await persist(exec)
2098
+ return json(res, { removed: true, rule: removed, status: statusView(exec) })
2099
+ }
2100
+ if (pathname === '/permgate/reload' && method === 'POST') {
2101
+ await load(exec)
2102
+ return json(res, statusView(exec))
2103
+ }
2104
+ // 打开配置文件:用系统默认关联的编辑器打开(Windows: cmd start)
2105
+ if (pathname === '/permgate/open-config' && method === 'POST') {
2106
+ await init(exec)
2107
+ const t = target
2108
+ if (!t) return json(res, { error: '配置文件路径未知' })
2109
+ const sub = ctx.get('subprocess')
2110
+ if (!sub) return json(res, { error: 'subprocess 服务不可用' })
2111
+ try {
2112
+ const winPath = fs.processPath ? fs.processPath(t) : String(t).replace(/\//g, '\\')
2113
+ const exe = await sub.resolveExecutable('cmd')
2114
+ const handle = sub.spawn({
2115
+ argv: [exe, '/c', 'start', '', winPath],
2116
+ cwd: String(root || 'C:\\').replace(/\//g, '\\'),
2117
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
2118
+ graceMs: 5000,
2119
+ })
2120
+ await handle.done
2121
+ // 附带 status:客户端 invoke 会把响应应用为面板状态,缺了会把配置路径冲掉
2122
+ return json(res, { ok: true, path: winPath, status: statusView(exec) })
2123
+ } catch (e) {
2124
+ return json(res, { error: '打开配置文件失败: ' + ((e && e.message) || String(e)) })
2125
+ }
2126
+ }
2127
+ // 打开被对比的文件:用系统默认关联的编辑器打开(同 open-config 的做法)。
2128
+ // 安全约束:仅文件读写工具(read/write/edit)的待审批 entry 可触发,且仅允许
2129
+ // 文本/文档类扩展名——`cmd /c start` 对 .exe/.bat/.ps1 等执行的是"运行"而非"编辑"。
2130
+ if (pathname === '/permgate/open-file' && method === 'POST') {
2131
+ const entry = pendingApprovals.get(a.id)
2132
+ if (!entry) return json(res, { ok: false, error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
2133
+ if (!FILE_READ_TOOLS[entry.tool] && !FILE_WRITE_TOOLS[entry.tool]) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported tool for opening file' : '该审批不支持打开文件' })
2134
+ const args = parseEntryArgs(entry)
2135
+ const fp = pathArg(args)
2136
+ if (!fp) return json(res, { ok: false, error: lang === 'en' ? 'Missing file path' : '缺少文件路径' })
2137
+ // 仅允许文本/文档类扩展名(点开头文件如 .gitignore 视为无扩展名,Windows 不会执行)
2138
+ const openBase = String(fp).split(/[\\/]/).pop() || ''
2139
+ const openExt = openBase.indexOf('.') > 0 ? openBase.slice(openBase.lastIndexOf('.') + 1).toLowerCase() : ''
2140
+ if (openExt && !OPEN_TEXT_EXTS.has(openExt)) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported file type: ' + openExt : '不支持打开该文件类型: ' + openExt })
2141
+ const sub = ctx.get('subprocess')
2142
+ if (!sub) return json(res, { ok: false, error: 'subprocess 服务不可用' })
2143
+ try {
2144
+ const t = await fs.resolve(resolveArgPath(fp, entry.projRoot))
2145
+ const winPath = fs.processPath ? fs.processPath(t) : String(t).replace(/\//g, '\\')
2146
+ const exe = await sub.resolveExecutable('cmd')
2147
+ const handle = sub.spawn({
2148
+ argv: [exe, '/c', 'start', '', winPath],
2149
+ cwd: String(root || 'C:\\').replace(/\//g, '\\'),
2150
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
2151
+ graceMs: 5000,
2152
+ })
2153
+ await handle.done
2154
+ return json(res, { ok: true, path: winPath })
2155
+ } catch (e) {
2156
+ return json(res, { ok: false, error: lang === 'en' ? 'Cannot open file: ' + ((e && e.message) || String(e)) : '打开文件失败: ' + ((e && e.message) || String(e)) })
2157
+ }
2158
+ }
2159
+ return json(res, { error: 'not found: ' + pathname }, 404)
2160
+ } catch (e) {
2161
+ console.error('[permgate] route error:', e)
2162
+ return json(res, { error: (e && e.message) ? e.message : String(e) }, 500)
2163
+ }
2164
+ }
2165
+
2166
+ onDispose(ctx.webServer.register({ kind: 'prefix', path: '/permgate', handler: routePermgate }))
2167
+ // SSE 心跳:每 30 秒向订阅者写注释帧,防止空闲连接被中间层掐断
2168
+ onDispose(ctx.timer.interval(() => {
2169
+ for (const res of sseClients) {
2170
+ try { res.write(': ka\n\n') } catch (e) { sseClients.delete(res) }
2171
+ }
2172
+ }, 30000))
2173
+
2174
+ // 启动后全量对齐:重启时恢复的会话由 dsh-permission-presets 的 pinInitialPermission
2175
+ // 按预设捆绑 seed,会话沙箱旋钮可能 ≠ permgate 配置(如项目配置 full access 被
2176
+ // 写回 workspace-write)。此时恢复会话的 seed 事件可能发生在插件加载之前,
2177
+ // session/event hook 捕捉不到。这里延迟等会话恢复完成后,对所有处于
2178
+ // 「自定义审查」的会话补一次同步,使配置真正生效。
2179
+ onDispose(ctx.timer.setTimeout(() => {
2180
+ (async () => {
2181
+ try {
2182
+ const all = ctx.sessions && typeof ctx.sessions.list === 'function' ? ctx.sessions.list() : []
2183
+ if (!Array.isArray(all)) return
2184
+ for (const s of all) {
2185
+ if (!s) continue
2186
+ try {
2187
+ const ex = { agent: { session: s } }
2188
+ await init(ex)
2189
+ syncSandbox(ex)
2190
+ } catch (e) {}
2191
+ }
2192
+ } catch (e) {
2193
+ console.error('[permgate] startup sandbox sync error:', e)
2194
+ }
2195
+ })()
2196
+ }, 1200))
2197
+
2198
+ // ── 工具注册 ────────────────────────────────────────────────────────────────
2199
+
2200
+ function renderer() {
2201
+ return function (_a, v) { return [{ type: 'text', text: JSON.stringify(v, null, 2) }] }
2202
+ }
2203
+
2204
+ function registerTool(definition) {
2205
+ onDispose(ctx.tools.register(defineTool(definition)))
2206
+ }
2207
+ // ── 工具注册 ────────────────────────────────────────────────────────────────
2208
+
2209
+ function renderer() {
2210
+ return function (_a, v) { return [{ type: 'text', text: JSON.stringify(v, null, 2) }] }
2211
+ }
2212
+
2213
+ function registerTool(definition) {
2214
+ onDispose(ctx.tools.register(defineTool(definition)))
2215
+ }
2216
+
2217
+ registerTool({
2218
+ name: 'perm_status',
2219
+ description: '查看权限网关(permgate)当前生效的分类默认(目录/命令/读取/编辑/子代理/重复操作)、例外、快捷工具、自定义规则、最近决策与配置路径。',
2220
+ parameters: {},
2221
+ output: { schema: { type: 'json' }, render: renderer() },
2222
+ async execute(_args, exec) { await init(exec); return statusView(exec) },
2223
+ })
2224
+
2225
+ registerTool({
2226
+ name: 'perm_set_category',
2227
+ description: '设置一个权限分类的默认动作。分类: directory=目录访问(工作区外), command=执行命令, read=读取文件, edit=编辑文件, subagent=启动子代理, doomloop=重复操作。动作: ask=询问, allow=允许, deny=拒绝; 项目(target=project)还支持 inherit=继承全局。',
2228
+ parameters: {
2229
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
2230
+ category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit', 'subagent', 'doomloop'] },
2231
+ mode: { type: 'string', required: true, enum: ['ask', 'allow', 'deny', 'inherit'], description: '目标动作;inherit 仅适用于项目' },
2232
+ },
2233
+ output: { schema: { type: 'json' }, render: renderer() },
2234
+ async execute(args, exec) {
2235
+ await init(exec)
2236
+ if (CATS.indexOf(args.category) === -1) return { error: '未知分类: ' + args.category }
2237
+ if (!setCategoryMode(args.target, args.category, args.mode)) return { error: '非法的 target/mode 组合' }
2238
+ await persist(exec)
2239
+ return statusView(exec)
2240
+ },
2241
+ })
2242
+
2243
+ registerTool({
2244
+ name: 'perm_add_exception',
2245
+ description: '给分类添加一条例外。directory/read/edit 分类用 path(路径 glob,支持 * 与 ** 通配,如 G:/MCP/**、**/*.env);command 分类用 match(命令名或子串,支持 * 通配任意剩余,如 Get-Item * / git status)。例外优先于分类默认动作,仅 allow/deny。',
2246
+ parameters: {
2247
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
2248
+ category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit'] },
2249
+ match: { type: 'string', required: true, description: '路径 glob 或命令名/子串(* 匹配任意剩余)' },
2250
+ action: { type: 'string', required: true, enum: ['allow', 'deny'], description: '命中例外后的动作' },
2251
+ reason: { type: 'string', description: '自定义拒绝原因(仅 deny 例外生效;allow 例外忽略)' },
2252
+ },
2253
+ output: { schema: { type: 'json' }, render: renderer() },
2254
+ async execute(args, exec) {
2255
+ await init(exec)
2256
+ if (EXC_CATS.indexOf(args.category) === -1) return { error: '该分类不支持例外' }
2257
+ if (!args.match || !String(args.match)) return { error: 'match 不能为空' }
2258
+ const e = normalizeException({ id: 'e' + Math.random().toString(36).slice(2, 8), action: args.action, reason: args.reason, path: args.category === 'command' ? undefined : args.match, match: args.category === 'command' ? args.match : undefined }, args.category)
2259
+ if (!e) return { error: '非法的例外参数' }
2260
+ const block = args.target === 'project' ? ensureProject() : config.global
2261
+ if (!block[args.category]) block[args.category] = freshCategory(args.category, args.target === 'project')
2262
+ if (!block[args.category].exceptions) block[args.category].exceptions = []
2263
+ block[args.category].exceptions.push(e)
2264
+ await persist(exec)
2265
+ return { added: e, status: statusView(exec) }
2266
+ },
2267
+ })
2268
+
2269
+ registerTool({
2270
+ name: 'perm_remove_exception',
2271
+ description: '按 id 删除一条分类例外(id 见 perm_status 返回的 exceptions 或 perm_add_exception 返回)。',
2272
+ parameters: {
2273
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
2274
+ category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit'] },
2275
+ id: { type: 'string', required: true },
2276
+ },
2277
+ output: { schema: { type: 'json' }, render: renderer() },
2278
+ async execute(args, exec) {
2279
+ await init(exec)
2280
+ const block = args.target === 'project' ? ensureProject() : config.global
2281
+ const cat = block[args.category]
2282
+ if (!cat || !Array.isArray(cat.exceptions)) return { removed: false, reason: '例外列表不存在', status: statusView(exec) }
2283
+ const idx = cat.exceptions.findIndex((r) => r.id === args.id)
2284
+ if (idx === -1) return { removed: false, reason: '未找到 id=' + args.id, status: statusView(exec) }
2285
+ const removed = cat.exceptions.splice(idx, 1)[0]
2286
+ await persist(exec)
2287
+ return { removed: true, exception: removed, status: statusView(exec) }
2288
+ },
2289
+ })
2290
+
2291
+ registerTool({
2292
+ name: 'perm_set_quick',
2293
+ description: '设置快捷工具默认动作(如 web_search/skill/grep/glob 等)。action=inherit 表示移除该项目覆盖(继承全局)。',
2294
+ parameters: {
2295
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
2296
+ tool: { type: 'string', required: true, description: '工具名,支持通配如 cordis_*' },
2297
+ action: { type: 'string', required: true, enum: ['ask', 'allow', 'deny', 'inherit'], description: '动作;inherit 移除' },
2298
+ },
2299
+ output: { schema: { type: 'json' }, render: renderer() },
2300
+ async execute(args, exec) {
2301
+ await init(exec)
2302
+ if (!args.tool || !String(args.tool)) return { error: 'tool 不能为空' }
2303
+ if (ALL_MODES.indexOf(args.action) === -1) return { error: '非法动作' }
2304
+ const block = args.target === 'project' ? ensureProject() : config.global
2305
+ if (!block.quickTools) block.quickTools = {}
2306
+ if (args.action === 'inherit') delete block.quickTools[args.tool]
2307
+ else block.quickTools[args.tool] = args.action
2308
+ await persist(exec)
2309
+ return statusView(exec)
2310
+ },
2311
+ })
2312
+
2313
+ registerTool({
2314
+ name: 'perm_add_rule',
2315
+ description: '新增一条自定义规则(通用匹配)。匹配器至少提供一个:tool=按工具名匹配(支持 * 与 ? 通配,如 cordis_*);path=匹配调用参数里任意路径字符串(glob);args=匹配序列化参数里的子串(如 rm -rf)。action: allow=放行,ask=弹审批,deny=拒绝。项目规则优先于全局规则。',
2316
+ parameters: {
2317
+ target: { type: 'string', required: true, enum: ['global', 'project'], description: '规则放在全局还是当前项目' },
2318
+ action: { type: 'string', required: true, enum: ['allow', 'ask', 'deny'], description: '命中后的动作' },
2319
+ tool: { type: 'string', description: '工具名通配,如 cordis_*' },
2320
+ path: { type: 'string', description: '路径 glob,匹配参数中的路径字符串' },
2321
+ args: { type: 'string', description: '参数子串,匹配序列化后的参数' },
2322
+ reason: { type: 'string', description: '命中时展示的原因' },
2323
+ },
2324
+ output: { schema: { type: 'json' }, render: renderer() },
2325
+ async execute(args, exec) {
2326
+ await init(exec)
2327
+ if (!args.tool && !args.path && !args.args) return { error: '至少提供 tool/path/args 之一' }
2328
+ const rule = normalizeRule({ id: 'r' + Math.random().toString(36).slice(2, 8), action: args.action, tool: args.tool, path: args.path, args: args.args, reason: args.reason })
2329
+ if (!rule) return { error: '非法的规则参数' }
2330
+ const block = args.target === 'project' ? ensureProject() : config.global
2331
+ if (!block.custom) block.custom = []
2332
+ block.custom.push(rule)
2333
+ await persist(exec)
2334
+ return { added: rule, status: statusView(exec) }
2335
+ },
2336
+ })
2337
+
2338
+ registerTool({
2339
+ name: 'perm_remove_rule',
2340
+ description: '按 id 删除一条自定义规则(id 见 perm_status 或 perm_add_rule 的返回)。',
2341
+ parameters: {
2342
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
2343
+ id: { type: 'string', required: true, description: '要删除的规则 id' },
2344
+ },
2345
+ output: { schema: { type: 'json' }, render: renderer() },
2346
+ async execute(args, exec) {
2347
+ await init(exec)
2348
+ const block = args.target === 'project' ? ensureProject() : config.global
2349
+ const list = block.custom || []
2350
+ const idx = list.findIndex((r) => r.id === args.id)
2351
+ if (idx === -1) return { removed: false, reason: '未找到 id=' + args.id, status: statusView(exec) }
2352
+ const removed = list.splice(idx, 1)[0]
2353
+ await persist(exec)
2354
+ return { removed: true, rule: removed, status: statusView(exec) }
2355
+ },
2356
+ })
2357
+
2358
+ registerTool({
2359
+ name: 'perm_reload',
2360
+ description: '从磁盘重新加载权限配置文件(手动编辑后调用)。',
2361
+ parameters: {},
2362
+ output: { schema: { type: 'json' }, render: renderer() },
2363
+ async execute(_args, exec) { await load(exec); return statusView(exec) },
2364
+ })
2365
+
2366
+ // 新会话默认权限修正已交由 dsh-permission-presets 0.1.2 原生处理:其在
2367
+ // session/created 钩子里调用 pinInitialPermission,为新会话 seed 用户默认
2368
+ // 预设、对 seeded/恢复会话保留其有效值。旧 0.1.1 时代这里的 re-seed 补偿
2369
+ // (检测「全新无活动 + 组合派生预设 ≠ 用户默认」后重定)已移除。
2370
+ ctx.on('session/created', (session) => {
2371
+ // 沙箱对齐:session/created 是同步 emit,本监听可能在 pinInitialPermission
2372
+ // 之前/之后执行、且恢复会话的 seed 事件在插件加载前已发生。延迟一 tick 后
2373
+ // 再按 permgate 配置对齐该会话沙箱(对 custom-review 会话幂等)。
2374
+ ctx.timer.setTimeout(() => {
2375
+ (async () => {
2376
+ try {
2377
+ const ex = { agent: { session } }
2378
+ await init(ex)
2379
+ syncSandbox(ex)
2380
+ } catch (e) {}
2381
+ })()
2382
+ }, 0)
2383
+ })
2384
+
2385
+ // ── 预执行审查 ──────────────────────────────────────────────────────────────
2386
+
2387
+ // 会话权限/沙箱/审批变化(DSH 侧写入,不经 permgate)→ 推送浏览器刷新,
2388
+ // 让快捷栏/设置页在选择器切换权限后立即联动。
2389
+ ctx.on('session/event', async (session, event) => {
2390
+ try {
2391
+ if (!event) return
2392
+ if (event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy') {
2393
+ if (agentRef === null && session) agentRef = { session }
2394
+ broadcast({ type: 'status' })
2395
+ }
2396
+ // 自动同步:用户在设置页/快捷栏切换权限预设(仅「自定义审查」)后,
2397
+ // 立即把 permgate 配置解析出的沙箱模式推给该会话,无需再手动去拨沙箱开关。
2398
+ // sandbox/mode 是我们 setSandboxMode 自己的回声,跳过以杜绝同步环。
2399
+ if (event.type === 'permission/preset' && session) {
2400
+ const exec = { agent: { session } }
2401
+ await init(exec)
2402
+ syncSandbox(exec)
2403
+ }
2404
+ } catch (e) {}
2405
+ })
2406
+
2407
+ ctx.on('tools/pre-execute', async (exec, next) => {
2408
+ try {
2409
+ await init(exec)
2410
+ // 注意:聊天/工具调用绝不改写会话 sandbox knob(否则权限选择器显示会漂移);
2411
+ // 底层沙箱只在设置页显式切换(/permgate/set-sandbox)或用户切换
2412
+ // 「自定义审查」预设时(session/event → permission/preset 自动同步)写入。
2413
+ const d = decide(exec)
2414
+ recordDecision(d, exec)
2415
+ if (typeof exec.name !== 'string' || exec.name.indexOf('perm_') !== 0) {
2416
+ recent.push(callKey(exec.name, exec.arguments))
2417
+ if (recent.length > 12) recent.splice(0, recent.length - 12)
2418
+ }
2419
+ console.log('[permgate]', d.action, exec.name, L(d.reason, uiLang) || '')
2420
+ if (d.action === 'ask') {
2421
+ const out = await askUser(exec, d)
2422
+ if (out.kind !== 'allow') return { kind: 'deny', reason: out.reason || (uiLang === 'en' ? 'User denied' : '用户拒绝') }
2423
+ } else if (d.action === 'deny') {
2424
+ return { kind: 'deny', reason: L(d.reason, uiLang) }
2425
+ }
2426
+ // 放行(或询问允许)后:写类工具 + 工作区外 + 沙箱受限 → 原生升级审批 → 临时放开
2427
+ flushStaleUpgrades()
2428
+ if (needsUpgrade(exec)) {
2429
+ const ok = await requireSandboxUpgrade(exec)
2430
+ if (!ok) return { kind: 'deny', reason: uiLang === 'en' ? 'Sandbox upgrade denied (write outside workspace)' : '沙箱升级被拒绝(工作区外写入)' }
2431
+ }
2432
+ return next()
2433
+ } catch (e) {
2434
+ console.error('[permgate] pre-execute error:', e)
2435
+ return next()
2436
+ }
2437
+ })
2438
+
2439
+ // 一次性沙箱升级:该调用执行完成后写回原沙箱
2440
+ ctx.on('tools/post-execute', async (exec, result, next) => {
2441
+ try {
2442
+ const rec = upgradedCalls.get(exec.token)
2443
+ if (rec) {
2444
+ upgradedCalls.delete(exec.token)
2445
+ try { if (rec.session) setSandboxMode(rec.session, rec.prev || 'workspace-write') } catch (e) {}
2446
+ }
2447
+ } catch (e) {}
2448
+ return next()
2449
+ })
2450
+ },
2451
+ }