@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/LICENSE +21 -0
- package/README.md +211 -0
- package/client.js +1867 -0
- package/cordis.patch.yml +35 -0
- package/index.js +2451 -0
- package/package.json +43 -0
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
|
+
}
|