@mrweicodes/dsh-permgate 1.3.11 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +211 -211
  2. package/client.js +247 -36
  3. package/index.js +1457 -392
  4. package/package.json +12 -2
package/index.js CHANGED
@@ -3,15 +3,33 @@
3
3
  // 配置持久化于 $DSH_HOME/dsh-permgate/config.json(用户级、不进任何 git 仓库)。
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
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']
6
+ import { join as pathJoin, resolve as pathResolve, isAbsolute as pathIsAbsolute } from 'node:path'
7
+ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, readdirSync as fsReaddirSync, unlinkSync as fsUnlinkSync, lstatSync as fsLstatSync, realpathSync as fsRealpathSync } from 'node:fs'
8
+ import { homedir as osHomedir } from 'node:os'
9
+ const CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo', 'subagent', 'doomloop']
10
+ const EXC_CATS = ['directory', 'command', 'read', 'image', 'edit', 'undo']
11
+ // 分类枚举清单派生:三处工具 schema 的 enum 直接引用,避免新增分类时逐处漏改
12
+ const CATEGORY_ENUM = CATS.slice()
13
+ const EXC_CATEGORY_ENUM = EXC_CATS.slice()
10
14
  const MODES = ['ask', 'allow', 'deny']
11
15
  const ALL_MODES = ['ask', 'allow', 'deny', 'inherit']
12
16
  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' }
17
+ // 快捷工具预设:无文件/命令语义、只能按工具名设默认动作的清单(设置页据此展示,新配置按 QUICK_DEFAULTS 落默认)
18
+ // 低风险观测/会话类工具默认放行,避免每次都弹窗;
19
+ // cordis_run(宿主进程内执行代码)、cordis_stop/undefine(管理动态插件)与 mcp__*(外装 MCP)不在此列,走兜底策略(默认询问)
20
+ const QUICK_DEFAULTS = {
21
+ web_search: 'ask', skill: 'allow', grep: 'allow', glob: 'allow', web_fetch: 'ask',
22
+ ask_user_question: 'allow', todo_write: 'allow', list_agents: 'allow',
23
+ job_list: 'allow', job_output: 'allow', job_kill: 'allow',
24
+ get_goal: 'allow', create_goal: 'allow', update_goal: 'allow',
25
+ send_message: 'allow', interrupt_agent: 'allow',
26
+ present: 'allow', exit_plan_mode: 'allow',
27
+ cordis_define: 'allow', cordis_inspect_list: 'allow', cordis_inspect_query: 'allow', cordis_inspect_self: 'allow',
28
+ }
29
+ // 单一来源:预设清单由 QUICK_DEFAULTS 的键派生(顺序即键的插入顺序),
30
+ // 避免「清单」与「默认值」两份定义在新增工具时漂移(设置页展示与 locked 迁移共用这一份)
31
+ const QUICK_PRESET = Object.keys(QUICK_DEFAULTS)
32
+ // eslint-disable-next-line no-unused-vars -- 有意保留:记录「审批已改为永不超时」前的历史口径
15
33
  const ASK_TIMEOUT_MS = 300000 // 保留常量(历史/文档用途);审批已改为永不超时
16
34
  const DECIDE_CHOICES = ['allow', 'deny', 'allow-global', 'allow-project', 'deny-global', 'deny-project']
17
35
  const REPEAT_STREAK = 4
@@ -19,17 +37,167 @@ const PS_KEYWORDS = { foreach: 1, if: 1, else: 1, elseif: 1, for: 1, while: 1, d
19
37
  // 子命令路由器命令族:候选细化到「git status *」这一粒度,而不是一放全放「git *」
20
38
  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
39
 
22
- const FILE_READ_TOOLS = { read: 1, read_image: 1 }
40
+ const FILE_READ_TOOLS = { read: 1 }
41
+ // 图片读取单列一类:判定链与 read 完全同构(工作区外先过「目录访问」闸,再过本分类 + 路径例外),
42
+ // 但配置与默认值都独立 —— 「读文件」的设置不管读图,且 image 默认 ask(read 默认 allow),
43
+ // 老配置升级后读图会先询问(v1 的 locked 配置保持 deny),是否放宽由用户自己决定。
44
+ const FILE_IMAGE_TOOLS = { read_image: 1 }
23
45
  const FILE_WRITE_TOOLS = { write: 1, edit: 1 }
24
46
  const COMMAND_TOOLS = { pwsh: 1, bash: 1 }
25
47
  const SUBAGENT_TOOLS = { subagent: 1, subagent_fork: 1, workflow: 1, ralph: 1 }
26
48
 
49
+ // str_replace_editor 的写命令(view 只读;undo_edit 只抛 E_UNSUPPORTED、不写盘,故不计入写)
50
+ const SRE_WRITE_CMDS = { create: 1, str_replace: 1, insert: 1 }
51
+
52
+ // str_replace_editor 的内核:DSH 内置(官方语义,insert_line 0 基、插到该行之后)
53
+ // 或 dsh-better-edit 的同名 shadow 覆盖(1 基、插到该行之前)。两者语义相反,
54
+ // 预览必须按实际生效的那个算,否则会把插入位置画到错误的地方。
55
+ const EDITOR_KERNELS = ['auto', 'builtin', 'shadow']
56
+ const EDITOR_KERNEL_VALUES = ['auto', 'builtin', 'shadow', 'inherit']
57
+
58
+ // str_replace_editor 命令名提取统一:isFileWrite/isFileRead 与各预览分支共用同一解析口径,
59
+ // 避免命令字符串解析在多处独立演化(create 等命令的判定曾在两处各写一份)
60
+ function sreCommand(args) {
61
+ try { return String((args && args.command) || '') } catch (e) { return '' }
62
+ }
63
+
64
+ // 文件写工具判定:write/edit 原生工具,或 str_replace_editor 的写命令
65
+ function isFileWrite(name, args) {
66
+ if (FILE_WRITE_TOOLS[name]) return true
67
+ if (name !== 'str_replace_editor') return false
68
+ return !!SRE_WRITE_CMDS[sreCommand(args)]
69
+ }
70
+
71
+ // 文件读(文本)工具判定:read,或 str_replace_editor 的 view
72
+ function isFileRead(name, args) {
73
+ if (FILE_READ_TOOLS[name]) return true
74
+ if (name !== 'str_replace_editor') return false
75
+ return sreCommand(args) === 'view'
76
+ }
77
+
78
+ // 图片读工具判定:read_image(参数同样取 file_path,路径解析复用 pathArg)
79
+ function isFileImage(name) {
80
+ return !!FILE_IMAGE_TOOLS[name]
81
+ }
82
+
83
+ // 撤销类工具(dsh-better-edit 的 undo_last_edit):会写盘但不是「编辑」——它恢复既有内容、
84
+ // 不接受调用方提供的新内容,因此单列一类(undo),默认询问。
85
+ const UNDO_TOOLS = { undo_last_edit: 1 }
86
+ function isUndo(name) { return !!UNDO_TOOLS[name] }
87
+
88
+ // 工具自身所属的路径类分类(不含 directory 闸):工作区外审批的「仅此文件」候选要写哪个分类,
89
+ // 不能靠 entry.cat —— 它只记录「作出决定的那道闸」,directory 处于 ask 时反映不出工具本身属于哪类。
90
+ function pathToolCat(name, args) {
91
+ if (isFileWrite(name, args)) return 'edit'
92
+ if (isFileImage(name)) return 'image'
93
+ if (isUndo(name)) return 'undo'
94
+ if (isFileRead(name, args)) return 'read'
95
+ return null
96
+ }
97
+
98
+ // 「可预览文件内容」判定:详情 diff 与「打开文件」路由共用同一口径(写类/文本读类/图片类/撤销类),
99
+ // 避免两处判据分叉导致「面板有对比但打开文件报不支持」
100
+ function isPreviewableFileTool(name, args) {
101
+ return !!(isFileWrite(name, args) || isFileRead(name, args) || isFileImage(name) || isUndo(name))
102
+ }
103
+
104
+ // ── 图片嗅探(详情缩略图用)────────────────────────────────────────
105
+ // read_image 支持 PNG/JPEG/WebP/GIF。这里不引图像库,直接按各格式文件头取格式与像素尺寸;
106
+ // 只在「详情」预览通道里用,嗅探失败即视为不可预览,不影响权限判定本身。
107
+ const IMAGE_MIME = { png: 'image/png', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
108
+ // 缩略图体积上限:data URL 比原字节还要大 1/3,超过就只给格式/尺寸、不返回图片本体
109
+ const IMAGE_MAX_BYTES = 2 * 1024 * 1024
110
+ // 像素/边长上限:服务端不做降采样,原图直接内联给浏览器解码,故这里等于「弹窗解码预算」——
111
+ // 16 MP ≈ 4096×4096 ≈ 64MB RGBA;边长闸与像素闸同量级,避免小体积超大清屏图(解压炸弹)。
112
+ const IMAGE_MAX_PIXELS = 16 * 1000 * 1000
113
+ const IMAGE_MAX_DIM = 4096
114
+ // 头部读取上限:JPEG 的 SOF 段可能落在较后面,64KB 足以覆盖常规图片
115
+ const IMAGE_HEAD_BYTES = 64 * 1024
116
+
117
+ function be32(b, o) { return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0 }
118
+ function le16(b, o) { return b[o] | (b[o + 1] << 8) }
119
+ function le24(b, o) { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) }
120
+
121
+ // 返回 { format, width, height };无法识别返回 null;尺寸取不到时宽高为 null
122
+ function sniffImage(b) {
123
+ if (!b || b.length < 16) return null
124
+ // PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 宽高在固定偏移(大端 32 位)
125
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
126
+ if (b.length < 24) return { format: 'png', width: null, height: null }
127
+ return { format: 'png', width: be32(b, 16), height: be32(b, 20) }
128
+ }
129
+ // GIF87a / GIF89a:逻辑屏幕宽高(小端 16 位)
130
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) {
131
+ return { format: 'gif', width: le16(b, 6), height: le16(b, 8) }
132
+ }
133
+ // JPEG:FF D8 之后逐段跳过,遇 SOFn 帧头取高宽(大端)
134
+ if (b[0] === 0xff && b[1] === 0xd8) {
135
+ let i = 2
136
+ while (i + 9 < b.length) {
137
+ if (b[i] !== 0xff) { i++; continue }
138
+ const m = b[i + 1]
139
+ if (m === 0xff) { i++; continue } // 段间填充字节
140
+ if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue } // 无长度字段的段
141
+ const len = (b[i + 2] << 8) | b[i + 3]
142
+ if (len < 2) break
143
+ const isSof = (m >= 0xc0 && m <= 0xc3) || (m >= 0xc5 && m <= 0xc7) || (m >= 0xc9 && m <= 0xcb) || (m >= 0xcd && m <= 0xcf)
144
+ if (isSof) return { format: 'jpeg', height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] }
145
+ if (m === 0xda) break // SOS:之后是压缩数据,不会再有尺寸段
146
+ i += 2 + len
147
+ }
148
+ return { format: 'jpeg', width: null, height: null }
149
+ }
150
+ // WebP:RIFF....WEBP + 变体块
151
+ if (b.length >= 30 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
152
+ const cc = String.fromCharCode(b[12], b[13], b[14], b[15])
153
+ if (cc === 'VP8X') return { format: 'webp', width: le24(b, 24) + 1, height: le24(b, 27) + 1 }
154
+ if (cc === 'VP8 ') return { format: 'webp', width: (b[26] | (b[27] << 8)) & 0x3fff, height: (b[28] | (b[29] << 8)) & 0x3fff }
155
+ if (cc === 'VP8L') {
156
+ const bits = (b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24)) >>> 0
157
+ return { format: 'webp', width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
158
+ }
159
+ return { format: 'webp', width: null, height: null }
160
+ }
161
+ return null
162
+ }
163
+
164
+ // target 归一化统一:缺失/非法一律落到 global(三个设置路由共用,避免漏改某处把项目设置写进全局)
165
+ function normTarget(a) {
166
+ return a && a.target === 'project' ? 'project' : 'global'
167
+ }
168
+
169
+ // 路径归一化(better-edit store 路径匹配共用):大小写/斜杠/尾斜杠归一化
170
+ function normPathKey(p) {
171
+ return String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
172
+ }
173
+
174
+ // 「文件过大」预检统一口径:size 为字节数(多字节 UTF-8 下 ≥ 字符数),超过上限即可安全提前拒绝;
175
+ // 各分支读盘后另有字符数兜底(磁盘全文 + 新增文本总长),两层守卫互补
176
+ function fileTooLarge(info, maxChars) {
177
+ return !!(info && typeof info.size === 'number' && info.size > maxChars)
178
+ }
179
+
180
+ // 「文件过大」字符数兜底统一:磁盘全文 + 新增文本总长超过 DIFF_MAX_CHARS 即拒绝。
181
+ // 各读盘分支共用,避免上限口径多份独立演化(文案由调用方按场景选择)。
182
+ // 常量必须定义在模块作用域:overMaxChars 是模块级函数,访问不到 apply() 内的局部常量
183
+ const DIFF_MAX_CHARS = 1048576
184
+
185
+ function overMaxChars(a, b) {
186
+ return String(a == null ? '' : a).length + String(b == null ? '' : b).length > DIFF_MAX_CHARS
187
+ }
188
+
27
189
  // 双语文案:bi(zh, en) 生成 {zh,en};L(obj, lang) 按语言取值(缺省回退中文)
28
190
  const bi = (zh, en) => ({ zh, en })
29
191
  const L = (o, lang) => (o && (o[lang] || o.zh)) || ''
30
192
  // 语言参数归一化:只有 en 用英文,其余(缺失/空/非法)一律中文
31
193
  const normLang = (v) => (v === 'en' ? 'en' : 'zh')
32
194
 
195
+ // 「读取失败」错误对象统一构造:各读盘/预检分支共用,避免中英文案与字段在多处独立演化
196
+ const readFail = (e) => {
197
+ const emsg = (e && e.message ? e.message : String(e))
198
+ return { zh: '读取失败: ' + emsg, en: 'Read failed: ' + emsg }
199
+ }
200
+
33
201
  export default {
34
202
  inject: ['fs', 'sandboxPolicy', 'tools', 'webServer', 'timer', 'approval', 'permissionPresets', 'sessions'],
35
203
  apply(ctx) {
@@ -46,6 +214,10 @@ export default {
46
214
  let loaded = false
47
215
  let agentRef = null
48
216
  let dshHomeCache = null
217
+ // home 解析失败后的抑制窗口:load/persist 一次流程内会多次调用 resolveDshHome,
218
+ // 失败即重试会导致每次调用都重新 spawn cmd 探测并刷错误日志
219
+ let dshHomeFailAt = 0
220
+ const HOME_FAIL_TTL_MS = 200
49
221
  let config = freshConfig()
50
222
  let loadError = null
51
223
  let saveError = null
@@ -68,20 +240,21 @@ export default {
68
240
  }
69
241
 
70
242
  function freshConfig() {
71
- const g = { quickTools: {}, custom: [], sandboxMode: 'danger-full-access' }
243
+ const g = { quickTools: {}, custom: [], sandboxMode: 'danger-full-access', fallbackMode: 'ask', editorKernel: 'auto' }
72
244
  for (const c of CATS) g[c] = freshCategory(c, false)
73
245
  for (const k of Object.keys(QUICK_DEFAULTS)) g.quickTools[k] = QUICK_DEFAULTS[k]
74
246
  return { global: g, projects: {} }
75
247
  }
76
248
 
77
249
  function freshProject() {
78
- const pb = { quickTools: {}, custom: [], sandboxMode: 'inherit' }
250
+ const pb = { quickTools: {}, custom: [], sandboxMode: 'inherit', fallbackMode: 'inherit', editorKernel: 'inherit' }
79
251
  for (const c of CATS) pb[c] = freshCategory(c, true)
80
252
  return pb
81
253
  }
82
254
 
83
255
  function freshCategory(key, inheritDefault) {
84
- const cat = { mode: inheritDefault ? 'inherit' : (key === 'directory' || key === 'command' || key === 'edit' || key === 'doomloop' ? 'ask' : 'allow') }
256
+ // 默认 ask:不可逆/越界/涉及外部执行或输入的分类(含读图)从严;read/subagent 这类只读或可回收的默认放行
257
+ const cat = { mode: inheritDefault ? 'inherit' : (key === 'directory' || key === 'command' || key === 'edit' || key === 'undo' || key === 'image' || key === 'doomloop' ? 'ask' : 'allow') }
85
258
  if (EXC_CATS.indexOf(key) !== -1) cat.exceptions = []
86
259
  return cat
87
260
  }
@@ -133,13 +306,13 @@ export default {
133
306
 
134
307
  function buildConfig(parsed) {
135
308
  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' }
309
+ 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', fallbackMode: MODES.indexOf(g.fallbackMode) !== -1 ? g.fallbackMode : 'ask', editorKernel: EDITOR_KERNELS.indexOf(g.editorKernel) !== -1 ? g.editorKernel : 'auto' }
137
310
  for (const c of CATS) global[c] = normalizeCategory(g[c], c, false)
138
311
  const projects = {}
139
312
  const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
140
313
  for (const key of Object.keys(rawProjects)) {
141
314
  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' }
315
+ 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', fallbackMode: ALL_MODES.indexOf(p.fallbackMode) !== -1 ? p.fallbackMode : 'inherit', editorKernel: EDITOR_KERNEL_VALUES.indexOf(p.editorKernel) !== -1 ? p.editorKernel : 'inherit' }
143
316
  for (const c of CATS) pb[c] = normalizeCategory(p[c], c, true)
144
317
  projects[key] = pb
145
318
  }
@@ -151,7 +324,10 @@ export default {
151
324
  const oldMode = ['off', 'permissive', 'locked'].indexOf(g.mode) !== -1 ? g.mode : 'off'
152
325
  const cfg = freshConfig()
153
326
  const map = { off: 'allow', permissive: 'allow', locked: 'deny' }
154
- for (const c of CATS) cfg.global[c].mode = map[oldMode] || 'allow'
327
+ // image 是本版新增的从严分类,不套用老模式映射:off/permissive 老配置按新默认 ask
328
+ // (升级后读图先询问,由用户决定是否放宽),locked 仍保持 deny,避免比旧行为更松。
329
+ for (const c of CATS) cfg.global[c].mode = c === 'image' ? (oldMode === 'locked' ? 'deny' : 'ask') : (map[oldMode] || 'allow')
330
+ cfg.global.fallbackMode = map[oldMode]
155
331
  cfg.global.doomloop.mode = oldMode === 'off' ? 'allow' : 'ask'
156
332
  if (oldMode === 'locked') {
157
333
  for (const k of Object.keys(cfg.global.quickTools)) cfg.global.quickTools[k] = 'deny'
@@ -160,13 +336,19 @@ export default {
160
336
  const rawProjects = parsed.projects && typeof parsed.projects === 'object' ? parsed.projects : {}
161
337
  for (const key of Object.keys(rawProjects)) {
162
338
  const p = rawProjects[key] && typeof rawProjects[key] === 'object' ? rawProjects[key] : {}
163
- const pm = ['off', 'permissive', 'locked'].indexOf(p.mode) !== -1 ? p.mode : 'off'
339
+ const explicitMode = ['off', 'permissive', 'locked'].indexOf(p.mode) !== -1
340
+ const pm = explicitMode ? p.mode : 'off'
164
341
  const pb = { quickTools: {}, custom: Array.isArray(p.rules) ? p.rules.map(normalizeRule).filter(Boolean) : [] }
165
342
  for (const c of CATS) {
166
343
  pb[c] = freshCategory(c, true)
344
+ // 同上:image 不套用老模式 —— 未显式配置的项目保持 inherit(跟随全局 ask),locked 仍 deny
345
+ if (c === 'image') { if (pm === 'locked') pb[c].mode = 'deny'; continue }
167
346
  pb[c].mode = pm === 'off' ? 'allow' : (map[pm] || 'allow')
168
347
  }
169
348
  pb.doomloop.mode = pm === 'off' ? 'allow' : 'ask'
349
+ // 仅显式配置过旧模式的项目保留旧行为(off→allow);未显式配置(缺省 off)用 inherit 跟随全局,
350
+ // 否则之后全局收紧兜底时这些老项目仍按 allow 静默放行
351
+ pb.fallbackMode = explicitMode ? (pm === 'off' ? 'allow' : map[pm]) : 'inherit'
170
352
  if (pm === 'locked') {
171
353
  for (const k of QUICK_PRESET) pb.quickTools[k] = 'deny'
172
354
  }
@@ -283,7 +465,7 @@ export default {
283
465
  // 写类工具 + 目标在工作区外 + 会话沙箱受限(workspace-write)→ 需要沙箱升级
284
466
  function needsUpgrade(exec) {
285
467
  try {
286
- if (!FILE_WRITE_TOOLS[exec.name]) return false
468
+ if (!isFileWrite(exec.name, exec.arguments) && !isUndo(exec.name)) return false
287
469
  const fp = pathArg(exec.arguments)
288
470
  if (!fp || !isOutside(fp, root)) return false
289
471
  const agent = (exec && exec.agent) || agentRef
@@ -320,18 +502,55 @@ export default {
320
502
  // 兜底:异常/取消路径残留的升级在下次调用前写回
321
503
  function flushStaleUpgrades() {
322
504
  if (!upgradedCalls.size) return
323
- for (const [tok, rec] of upgradedCalls) {
505
+ for (const rec of upgradedCalls.values()) {
324
506
  try { if (rec && rec.session) setSandboxMode(rec.session, rec.prev || 'workspace-write') } catch (e) {}
325
507
  }
326
508
  upgradedCalls.clear()
327
509
  }
328
510
 
511
+ // 从宿主环境变量/系统 home 解析 DSH home(跨平台,且能省掉一次 cmd 子进程探测):
512
+ // DSH_HOME 本身即 home;否则 用户家目录 + '/.dsh'
513
+ function homeFromEnv() {
514
+ try {
515
+ const win = process.platform === 'win32'
516
+ // win32 上还要求盘符或 UNC 前缀(charCode 92 为反斜杠、47 为斜杠),
517
+ // 避免 /c/Users/x 这类取值被 path.resolve 解析到当前盘
518
+ const isWinAbs = (s) => (s.length > 2 && s.charAt(1) === ':' && (s.charCodeAt(2) === 92 || s.charCodeAt(2) === 47)) || (s.charCodeAt(0) === 92 && s.charCodeAt(1) === 92)
519
+ const localAbs = (v) => {
520
+ const s = String(v == null ? '' : v).trim()
521
+ if (!s || !pathIsAbsolute(s)) return null
522
+ if (win && !isWinAbs(s)) return null
523
+ return norm(s)
524
+ }
525
+ // 顺序与 harness(@deepseek-ai/dsh-home-paths)一致:DSH_HOME → os.homedir()/.dsh → HOME/USERPROFILE
526
+ const dh = localAbs(process.env.DSH_HOME)
527
+ if (dh) return dh
528
+ const oh = osHomedir()
529
+ if (oh) return norm(String(oh) + '/.dsh')
530
+ const h = localAbs(process.env.HOME || process.env.USERPROFILE)
531
+ if (h) return norm(h + '/.dsh')
532
+ } catch (e) {}
533
+ return null
534
+ }
535
+
536
+ // home 配置目标路径统一:ensureTarget/persist/probeHomeConfig/load 共用同一拼接,
537
+ // 避免同一路径多处内联后漂移(读一个文件、写另一个文件)
538
+ async function homeConfigTarget(home) {
539
+ return await fs.resolve(String(home) + '/dsh-permgate/config.json')
540
+ }
541
+
329
542
  async function resolveDshHome() {
330
543
  if (dshHomeCache !== null) return dshHomeCache
331
- dshHomeCache = ''
544
+ // 失败不缓存(初始化早期 subprocess 可能未就绪,保持 null 以便后续重试),
545
+ // 但抑制短时间内重复重试:load/persist 一次流程会多次调用本函数
546
+ if (dshHomeFailAt && Date.now() - dshHomeFailAt < HOME_FAIL_TTL_MS) return null
547
+ // 先看宿主环境变量与系统 home(跨平台):cmd 探测只在 Windows 可用,
548
+ // 仅依赖它会让 macOS/Linux 上 home 恒解析失败、配置永久无法落盘
549
+ const envHome = homeFromEnv()
550
+ if (envHome) { dshHomeCache = envHome; return dshHomeCache }
332
551
  try {
333
552
  const sub = ctx.get('subprocess')
334
- if (!sub) return dshHomeCache
553
+ if (!sub) { dshHomeFailAt = Date.now(); return dshHomeCache }
335
554
  const exe = await sub.resolveExecutable('cmd')
336
555
  const tryEcho = async (expr) => {
337
556
  const handle = sub.spawn({
@@ -357,6 +576,7 @@ export default {
357
576
  } catch (e) {
358
577
  console.error('[permgate] resolveDshHome error:', e)
359
578
  }
579
+ dshHomeFailAt = Date.now()
360
580
  return dshHomeCache
361
581
  }
362
582
 
@@ -369,8 +589,14 @@ export default {
369
589
  root = base
370
590
  rootSource = source
371
591
  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)
592
+ const resolved = home
593
+ ? await homeConfigTarget(home)
594
+ : await fs.resolve(base ? base + '/.dsh/.permgate.json' : '.dsh/.permgate.json')
595
+ // 配置路径发生切换时不重置磁盘快照:persist 的防覆盖守卫(磁盘内容 vs 快照)依赖它,
596
+ // 置空会让守卫整段跳过,可能用「旧路径加载的内存配置」静默覆盖新路径上已存在的配置。
597
+ // 切换后由 load() 对新目标重新建立快照基线;若 persist 在切换后未经 load 直接保存,
598
+ // 守卫会因新旧目标内容不一致而拒绝并提示「重新加载配置文件」,方向安全。
599
+ target = resolved
374
600
  return target
375
601
  }
376
602
 
@@ -394,22 +620,10 @@ export default {
394
620
  await handle.done
395
621
  return true
396
622
  }
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
623
+ // home 不可用(初始化早期 subprocess 未就绪等)时不再创建目录:旧实现在此
624
+ // mkdir <root>/.dsh,而 home 恢复后配置写回 home,项目里只留下一个空 .dsh。
625
+ // 配置目录应与实际写入位置一致;home 不可用是暂时状态,重试即恢复。
626
+ return false
413
627
  } catch (e) {
414
628
  console.error('[permgate] ensureConfigDir error:', e)
415
629
  return false
@@ -481,6 +695,8 @@ export default {
481
695
  function matchCommand(pat, hay) {
482
696
  const p = String(pat || '')
483
697
  const h = String(hay || '')
698
+ // 空 pattern 不匹配任何命令(否则 indexOf('') === 0 恒真,等于放行所有命令)
699
+ if (!p) return false
484
700
  if (p.indexOf('*') === -1 && p.indexOf('?') === -1) return h.toLowerCase().indexOf(p.toLowerCase()) !== -1
485
701
  let body = p
486
702
  let tail = '.*'
@@ -531,7 +747,15 @@ export default {
531
747
  }
532
748
 
533
749
  function ensureProject() {
534
- if (!config.projects[root]) config.projects[root] = freshProject()
750
+ // 与 projectBlock() 同口径查找:迁移来的 key 可能只是大小写/斜杠形式不同,
751
+ // 若这里用精确 root 查找会另建一个条目,同一项目出现两个 key(面板改动看似无效)
752
+ const key = norm(root).toLowerCase()
753
+ const projs = config.projects || {}
754
+ for (const k of Object.keys(projs)) {
755
+ if (norm(k).toLowerCase() === key) return projs[k]
756
+ }
757
+ if (!config.projects) config.projects = {}
758
+ config.projects[root] = freshProject()
535
759
  return config.projects[root]
536
760
  }
537
761
 
@@ -544,6 +768,75 @@ export default {
544
768
  return true
545
769
  }
546
770
 
771
+ // 覆盖语义统一:project 显式配置优先、inherit 穿透到 global、缺省用 def。
772
+ // fallback 与分类 mode 共用(缺省值不同:fallback='ask'、分类='allow'),避免覆盖判定独立演化
773
+ function firstEffective(projVal, globalVal, def) {
774
+ if (projVal && projVal !== 'inherit') return projVal
775
+ return globalVal || def
776
+ }
777
+
778
+ // 编辑器内核判别:str_replace_editor 可能被 dsh-better-edit 的同名 shadow 实现覆盖,
779
+ // 两者 insert 的 insert_line 语义相反(内置 0 基、插到该行之后 / shadow 1 基、插到该行之前),
780
+ // 预览必须按实际生效的那个算,否则会把插入位置画到错误的地方。
781
+ // 判别顺序:显式配置 > 工具描述探测 > 回退内置(内置始终存在)。
782
+ function editorKernelSetting() {
783
+ const proj = projectBlock()
784
+ return firstEffective(proj && proj.editorKernel, config.global.editorKernel, 'auto')
785
+ }
786
+
787
+ function detectEditorKernel(exec) {
788
+ try {
789
+ const tools = ctx.tools
790
+ if (!tools || typeof tools.get !== 'function') return null
791
+ const def = tools.get('str_replace_editor', (exec && exec.agent) || agentRef)
792
+ // 内置的「AFTER the line」只出现在 insert_line 的**参数**描述里(顶层描述没有该短语),
793
+ // 故把参数描述一并纳入匹配,使两种内核都能被正向识别,而不是让内置只能靠回退
794
+ const top = def && typeof def.description === 'string' ? def.description : ''
795
+ const params = def && def.parameters && typeof def.parameters === 'object' ? def.parameters : null
796
+ // defineTool 编译后 parameters 是 JSON Schema({type:'object', properties:{...}}),
797
+ // 参数描述在 properties.insert_line.description;兼容可能存在的旧式扁平结构
798
+ const props = params && params.properties && typeof params.properties === 'object' ? params.properties : params
799
+ const insDesc = (props && props.insert_line && typeof props.insert_line.description === 'string') ? props.insert_line.description : ''
800
+ const desc = top + '\n' + insDesc
801
+ if (!desc.trim()) return null
802
+ // shadow: "inserts new line(s) before insert_line (1-indexed, lines+1 appends)"
803
+ if (/before\s+insert_line/i.test(desc) || /1-indexed/i.test(desc)) return 'shadow'
804
+ // 内置: "The `new_str` will be inserted AFTER the line `insert_line`"
805
+ if (/AFTER the line/i.test(desc)) return 'builtin'
806
+ return null
807
+ } catch (e) { return null }
808
+ }
809
+
810
+ function resolveEditorKernel(exec) {
811
+ const setting = editorKernelSetting()
812
+ if (setting === 'builtin' || setting === 'shadow') return { kernel: setting, source: 'config' }
813
+ const detected = detectEditorKernel(exec)
814
+ if (detected) return { kernel: detected, source: 'detected' }
815
+ return { kernel: 'builtin', source: 'fallback' }
816
+ }
817
+
818
+ function setEditorKernel(targetKey, mode) {
819
+ const allowed = targetKey === 'global' ? EDITOR_KERNELS : EDITOR_KERNEL_VALUES
820
+ if (allowed.indexOf(mode) === -1) return false
821
+ const block = targetKey === 'global' ? config.global : ensureProject()
822
+ block.editorKernel = mode
823
+ return true
824
+ }
825
+
826
+ // 兜底策略:未匹配任何规则的调用如何处理(project 覆盖 global,默认 ask)
827
+ function fallbackMode() {
828
+ const proj = projectBlock()
829
+ return firstEffective(proj && proj.fallbackMode, config.global.fallbackMode, 'ask')
830
+ }
831
+
832
+ function setFallbackMode(targetKey, mode) {
833
+ const allowed = targetKey === 'global' ? MODES : ALL_MODES
834
+ if (allowed.indexOf(mode) === -1) return false
835
+ const block = targetKey === 'global' ? config.global : ensureProject()
836
+ block.fallbackMode = mode
837
+ return true
838
+ }
839
+
547
840
  function matchException(r, value, kind) {
548
841
  if (kind === 'path') return matchGlob(r.path, value)
549
842
  return matchCommand(r.match, value)
@@ -559,14 +852,17 @@ export default {
559
852
  const gl = Array.isArray(gCat.exceptions) ? gCat.exceptions : []
560
853
  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
854
  }
562
- const mode = (pCat && pCat.mode && pCat.mode !== 'inherit') ? pCat.mode : (gCat.mode || 'allow')
855
+ const mode = firstEffective(pCat && pCat.mode, gCat.mode, 'allow')
563
856
  return { action: mode, ruleId: null }
564
857
  }
565
858
 
566
859
  function pathArg(args) {
567
860
  try {
568
861
  if (!args || typeof args !== 'object') return null
569
- // read/edit 工具用 path,write 工具用 file_path;两类都取,缺省取不到返回 null
862
+ // 工具参数里的文件路径:str_replace_editor(内置与 better-edit shadow)只读 path,
863
+ // 而 read/write 用 file_path。若统一让 file_path 优先,agent 同时传两个字段时就会
864
+ // 「审查/预览看一个文件、实际写另一个文件」,故先按工具语义取 path
865
+ if (typeof args.command === 'string' && typeof args.path === 'string') return args.path
570
866
  if (typeof args.file_path === 'string') return args.file_path
571
867
  if (typeof args.path === 'string') return args.path
572
868
  return null
@@ -602,7 +898,6 @@ export default {
602
898
  // 过大时走 fallback 旧式 ± 视图(前 200 变更行 + 截断计数,不阻塞审批)。
603
899
  // DIFF_MAX_CHARS:对比双方文本总长上限(edit 为磁盘全文+新文本;write 为磁盘+内容)。
604
900
  // 1MB 覆盖常见大文件(如打包产物);超限返回「文件过大,无法生成对比」。
605
- const DIFF_MAX_CHARS = 1048576
606
901
  const DIFF_MAX_LINES = 200
607
902
  // Myers 中间区行数预算:超限走旧式 fallback(避免 trace 内存暴涨)。2048 行最坏时
608
903
  // trace 累计约 33MB 瞬时分配 + 数百万次迭代(服务端主线程);512 行时约 2MB/数十万次,
@@ -616,14 +911,35 @@ export default {
616
911
  function splitDiffLines(s) {
617
912
  return normEol(s).split('\n')
618
913
  }
619
- function computeLineDiff(oldText, newText) {
914
+
915
+ // 统计字符串 [0, end) 区间的换行数:只计数不物化数组
916
+ //(避免为取一个行号对最大 1MB 文本 split 出数十万元素的数组)
917
+ function countNewlines(s, end) {
918
+ const t = String(s == null ? '' : s)
919
+ const n = Math.min(typeof end === 'number' ? end : t.length, t.length)
920
+ let c = 0
921
+ for (let i = 0; i < n; i++) if (t.charCodeAt(i) === 10) c++
922
+ return c
923
+ }
924
+
925
+ // 公共前缀/后缀长度(行对齐共用):computeLineDiff 与 undo 窗口预览复用,避免同一算法两份实现漂移
926
+ function commonPrefixLen(a, b) {
927
+ const maxP = Math.min(a.length, b.length)
928
+ let p = 0
929
+ while (p < maxP && a[p] === b[p]) p++
930
+ return p
931
+ }
932
+ function commonSuffixLen(a, b, prefix) {
933
+ let s = 0
934
+ while (s < a.length - prefix && s < b.length - prefix && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
935
+ return s
936
+ }
937
+ function computeLineDiff(oldText, newText, baseLine) {
938
+ const b = baseLine || 1
620
939
  const oldLines = splitDiffLines(oldText)
621
940
  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++
941
+ const prefix = commonPrefixLen(oldLines, newLines)
942
+ const suffix = commonSuffixLen(oldLines, newLines, prefix)
627
943
  const removed = oldLines.slice(prefix, oldLines.length - suffix)
628
944
  const added = newLines.slice(prefix, newLines.length - suffix)
629
945
  const lines = []
@@ -633,7 +949,7 @@ export default {
633
949
  for (let i = 0; i < n; i++) {
634
950
  if (lines.length >= DIFF_MAX_LINES) break
635
951
  // 行号:差异区从 prefix+1 行开始;删除行标旧文件行号,新增行标新文件行号
636
- const no = String(prefix + i + 1).padStart(4, ' ')
952
+ const no = String(prefix + i + b).padStart(4, ' ')
637
953
  if (i < removed.length) { lines.push('- ' + no + ' ' + removed[i]); shownR++ }
638
954
  if (i < added.length) { lines.push('+ ' + no + ' ' + added[i]); shownA++ }
639
955
  }
@@ -731,11 +1047,8 @@ export default {
731
1047
  const base = baseLine || 1
732
1048
  const oldLines = splitDiffLines(oldText)
733
1049
  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++
1050
+ const p = commonPrefixLen(oldLines, newLines)
1051
+ const s = commonSuffixLen(oldLines, newLines, p)
739
1052
  const midA = oldLines.slice(p, oldLines.length - s)
740
1053
  const midB = newLines.slice(p, newLines.length - s)
741
1054
  // 完全相同(含空窗口):无差异,直接返回空 ops。
@@ -749,7 +1062,7 @@ export default {
749
1062
  // (added - removed === midB.length - midA.length),Myers 结果必被丢弃,直接走 fallback,
750
1063
  // 避免无谓的 O((N+M)*D) 计算与 trace 内存。
751
1064
  if (Math.abs(midA.length - midB.length) > DIFF_MAX_LINES) {
752
- const d = computeLineDiff(oldText, newText)
1065
+ const d = computeLineDiff(oldText, newText, base)
753
1066
  return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
754
1067
  }
755
1068
  if (midA.length + midB.length <= DIFF_BUDGET_LINES) {
@@ -774,7 +1087,7 @@ export default {
774
1087
  return { ok: true, kind, file: fp, added, removed, ops: out, truncated: 0 }
775
1088
  }
776
1089
  }
777
- const d = computeLineDiff(oldText, newText)
1090
+ const d = computeLineDiff(oldText, newText, base)
778
1091
  return { ok: true, kind, file: fp, fallback: true, added: d.added, removed: d.removed, lines: d.lines, truncated: d.truncated }
779
1092
  }
780
1093
  // 新文件(write 到不存在路径):全部为新增行
@@ -805,53 +1118,101 @@ export default {
805
1118
  return null
806
1119
  } catch (e) { return null }
807
1120
  }
1121
+ // better-edit store 定位缓存(projRoot → store 路径):runtime 目录扫描是 30+ 次文件 IO,
1122
+ // 每次 undo/edit 预览详情都重复执行;projRoot 在会话内不变,缓存安全
1123
+ const betterEditStoreCache = new Map()
1124
+ // store 缓存失效统一:DB 打开/查询失败时清掉正缓存,避免坏结果被固化
1125
+ function invalidateStoreCache(projRoot) {
1126
+ try { betterEditStoreCache.delete(projRoot) } catch (e) {}
1127
+ }
808
1128
  // 扫描 better-edit runtime 目录,返回匹配 projRoot 的 store 路径(.wsPath sidecar 匹配)
809
1129
  function betterEditStoreFor(projRoot) {
1130
+ if (betterEditStoreCache.has(projRoot)) return betterEditStoreCache.get(projRoot)
1131
+ let found = null
810
1132
  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
1133
+ // homeFromEnv()/harness 同口径:DSH_HOME 本身即 home,其它情况才是「用户家目录 + '/.dsh'」
1134
+ const base = homeFromEnv()
1135
+ if (base) {
1136
+ const rt = pathJoin(base, 'plugins', 'dsh-better-edit', 'runtime')
1137
+ if (fsExistsSync(rt)) {
1138
+ const want = normPathKey(projRoot)
1139
+ for (const dir of fsReaddirSync(rt)) {
1140
+ const full = pathJoin(rt, dir)
1141
+ const wsPath = pathJoin(full, '.wsPath')
1142
+ let ws = null
1143
+ try { ws = fsReadFileSync(wsPath, 'utf8').trim() } catch (e) {}
1144
+ if (ws && normPathKey(ws) === want) {
1145
+ const store = pathJoin(full, 'hash-store.sqlite')
1146
+ if (fsExistsSync(store)) { found = store; break }
1147
+ }
1148
+ }
827
1149
  }
828
1150
  }
829
- return null
830
- } catch (e) { return null }
831
- }
832
- // better-edit store 读取目标文件的 hashes 数组(按行)。path 匹配做大小写/斜杠归一化。
833
- async function betterEditHashesFor(projRoot, targetPath) {
1151
+ } catch (e) { found = null }
1152
+ // 负缓存修复:store 未创建(found=null)时不缓存,避免 better-edit 后续创建 store
1153
+ // 本会话永远找不到;正结果仍缓存(命中路径性能不受影响)
1154
+ if (found) betterEditStoreCache.set(projRoot, found)
1155
+ return found
1156
+ }
1157
+ // store 查询路径统一提取:resolve 结果对象 → targetKey(realpath)优先,displayPath 次之,fallback 兜底。
1158
+ // betterEditHashesFor 与 buildUndoDiffData 共用,避免 fallback 语义分叉('' vs fp)
1159
+ // resolve 结果对象 → 原生路径字符串统一口径:processPath(targetKey=realpath)优先,
1160
+ // 其次 targetKey/displayPath,最后 fallback。store 查询、持久化路径比较与迁移清理共用,
1161
+ // 避免「比较用路径」与「操作用路径」分叉(曾导致删除落到 realpath 指向的工作区外文件)
1162
+ function pathString(v, fallback) {
1163
+ if (typeof v === 'string') return v
1164
+ if (v && typeof v === 'object') {
1165
+ const viaProcess = fs.processPath && fs.processPath(v)
1166
+ return String(viaProcess || v.targetKey || v.displayPath || fallback || '')
1167
+ }
1168
+ return String(v == null ? (fallback || '') : v)
1169
+ }
1170
+
1171
+ // store 查询路径统一提取:betterEditHashesFor 与 buildUndoDiffData 共用(复用统一 pathString)
1172
+ function extractStorePath(v, fallback) {
1173
+ return pathString(v, fallback)
1174
+ }
1175
+ // better-edit store 行查找统一:WHERE path 精确查询(走主键),miss 时全表按归一化匹配回退。
1176
+ // undo/snapshots 两表共用,避免查询策略独立演化(table/cols 均为内部常量,无注入面)
1177
+ function storeRowByPath(db, table, cols, rawPath, want) {
1178
+ const exact = String(rawPath || '')
1179
+ const row = db.prepare('SELECT ' + cols + ' FROM ' + table + ' WHERE path = ?').get(exact) || null
1180
+ if (row) return row
1181
+ // 回退:只取主键列做归一化匹配,命中后再按主键取大列——一次 miss 不应把整表大字段
1182
+ // (undo 的 content/result_content 是编辑前后全文)全部物化为 JS 字符串
1183
+ const keys = db.prepare('SELECT path FROM ' + table).all()
1184
+ let hit = null
1185
+ for (const r of keys) { if (normPathKey(r.path) === want) { hit = r.path; break } }
1186
+ if (hit === null) return null
1187
+ return db.prepare('SELECT ' + cols + ' FROM ' + table + ' WHERE path = ?').get(hit) || null
1188
+ }
1189
+
1190
+ async function betterEditHashesFor(projRoot, targetPath, fallbackFp) {
834
1191
  const storePath = betterEditStoreFor(projRoot)
835
1192
  if (!storePath) return null
836
1193
  try {
837
1194
  const { DatabaseSync } = await import('node:sqlite')
838
- const normKey = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
839
- const want = normKey(targetPath)
1195
+ // fsService.resolve 可能返回 {displayPath, targetKey} 对象(dsh-fs-local resolveLocalTarget),
1196
+ // 先提取路径字符串,否则 String() 恒得 '[object Object]',WHERE 与归一化匹配全部落空
1197
+ // targetKey(realpath)优先:store 的 path 列存的是磁盘真实大小写,用输入大小写的
1198
+ // displayPath 做 WHERE 在 Windows 上大小写不一致时确定 miss、回退全表载入大列
1199
+ const rawPath = extractStorePath(targetPath, fallbackFp || '')
1200
+ const want = normPathKey(rawPath)
840
1201
  const db = new DatabaseSync(storePath, { readOnly: true })
841
1202
  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
- }
1203
+ // 优先按 path 精确查询(snapshots path 是主键,可走索引,避免全表载入全部 hashes);
1204
+ // 存储格式与磁盘路径可能不完全一致(大小写/斜杠),miss 时回退全表按归一化匹配。
1205
+ const row = storeRowByPath(db, 'snapshots', 'path, hashes', rawPath, want)
1206
+ if (row) {
1207
+ try {
1208
+ const arr = JSON.parse(row.hashes)
1209
+ if (Array.isArray(arr)) return arr
1210
+ } catch (e) {}
1211
+ return null
851
1212
  }
852
1213
  return null
853
1214
  } finally { try { db.close() } catch (e) {} }
854
- } catch (e) { return null }
1215
+ } catch (e) { invalidateStoreCache(projRoot); return null }
855
1216
  }
856
1217
 
857
1218
  // ── 从磁盘内容重算 better-edit 行 hash ─────────────────────────────
@@ -875,8 +1236,9 @@ export default {
875
1236
  if (beHasherP) return beHasherP
876
1237
  beHasherP = (async () => {
877
1238
  // 在 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')
1239
+ // homeFromEnv()/harness 同口径(见 betterEditStoreFor)
1240
+ const base = homeFromEnv()
1241
+ if (!base) return null
880
1242
  const profileNm = pathJoin(base, 'profiles', 'web', 'node_modules', '.pnpm')
881
1243
  const dirs = fsExistsSync(profileNm) ? fsReaddirSync(profileNm) : []
882
1244
  let entry = null
@@ -944,11 +1306,10 @@ export default {
944
1306
  let maxLine = -Infinity
945
1307
  for (const idx of indices) {
946
1308
  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] }
1309
+ const e = editTuple(raw)
949
1310
  const fromHash = betterEditAnchor(e && e.remove_from)
950
1311
  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') : ''
1312
+ const repl = editReplNorm(raw)
952
1313
  if (!fromHash || !toHash) return null
953
1314
  // 在当前(已部分应用)的内容上找锚点:仅未变行的原 hash 有效
954
1315
  const start = curHash.indexOf(fromHash)
@@ -978,18 +1339,227 @@ export default {
978
1339
  } catch (e) { return null }
979
1340
  }
980
1341
 
1342
+ // 撤销(undo_last_edit)的对比数据:读 better-edit 的 undo 行取「撤销后内容」,
1343
+ // 与磁盘当前内容做窗口 diff。文件在编辑后被改动时 better-edit 会拒绝撤销(E_UNDO_STALE),
1344
+ // 此处按同一条件提示,避免展示一次不会执行的变化。
1345
+ // 窗口化 diff 共用:给定变化区(1 基 changeStart 起始行 + old/new 侧变化行数),
1346
+ // 生成前后各 W 行的展示窗口并交给 diffPayloadOrFallback。undo/insert 预览共用,
1347
+ // 避免窗口公式多份独立演化(曾因此产生 insert 预览 off-by-one)。
1348
+ function windowedDiffPayload(fp, oldLines, newLines, changeStart, oldSpan, newSpan, W) {
1349
+ const winStart = Math.max(1, changeStart - W)
1350
+ const winOldText = oldLines.slice(winStart - 1, Math.min(oldLines.length, changeStart - 1 + oldSpan + W)).join('\n')
1351
+ const winNewText = newLines.slice(winStart - 1, Math.min(newLines.length, changeStart - 1 + newSpan + W)).join('\n')
1352
+ return diffPayloadOrFallback(fp, winOldText, winNewText, 'modified', winStart)
1353
+ }
1354
+
1355
+ // 插入预览统一:内置(0 基 after)与 shadow(1 基 before)只差插入点索引与上限的换算,
1356
+ // 参数校验、越界文案与窗口渲染全部共用,避免两侧独立演化(该公式历史上出现过 off-by-one)
1357
+ function previewInsert(fp, oldLines, addedLines, at, maxAt) {
1358
+ if (!Number.isInteger(at) || at < 0) return { ok: false, error: bi('insert_line 无效,无法预览', 'Invalid insert_line; cannot preview') }
1359
+ if (at > maxAt) return { ok: false, error: bi('插入位置超出文件范围', 'Insert position is beyond end of file') }
1360
+ // 只构造窗口范围(±W 行)再交给 diffPayloadOrFallback:避免为渲染小窗口深拷贝整文件行数组
1361
+ const W = 200
1362
+ const winStart = Math.max(0, at - W)
1363
+ const winEnd = Math.min(oldLines.length, at + W)
1364
+ const winOld = oldLines.slice(winStart, winEnd)
1365
+ const k = at - winStart
1366
+ const winNew = winOld.slice(0, k).concat(addedLines, winOld.slice(k))
1367
+ return diffPayloadOrFallback(fp, winOld.join('\n'), winNew.join('\n'), 'modified', winStart + 1)
1368
+ }
1369
+
1370
+ async function buildUndoDiffData(entry, fsService, fp) {
1371
+ if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
1372
+ let row = null
1373
+ // 先 stat + size 预检,再读 DB:避免 >1MB 文件先全量载入 undo 大行(content/result_content 各约等于文件大小)
1374
+ const st = await statTargetChecked(fp, entry.projRoot, fsService)
1375
+ if (!st.ok) return st
1376
+ const target = st.target
1377
+ const info = st.info
1378
+ try {
1379
+ const storePath = betterEditStoreFor(entry.projRoot)
1380
+ const resolved0 = target
1381
+ if (storePath) {
1382
+ const { DatabaseSync } = await import('node:sqlite')
1383
+ const db = new DatabaseSync(storePath, { readOnly: true })
1384
+ try {
1385
+ const rawPath = extractStorePath(resolved0, fp)
1386
+ const want = normPathKey(rawPath)
1387
+ // undo 行 content/result_content 为历史全文(编辑时大小、无上限):读行前先做 SQL 层
1388
+ // 大小预检,避免 >1MB 历史行被全量载入后才被字符数兜底拒绝(WHERE 精确命中时有效)
1389
+ const lenRow = db.prepare('SELECT LENGTH(content) + LENGTH(result_content) AS total FROM undo WHERE path = ?').get(String(rawPath || ''))
1390
+ if (lenRow && typeof lenRow.total === 'number' && lenRow.total > DIFF_MAX_CHARS) {
1391
+ return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1392
+ }
1393
+ // 优先按 path 精确查询(undo 表 path 是主键,可走索引,避免全表载入全部历史编辑内容);
1394
+ // 存储格式与磁盘路径可能不完全一致(大小写/斜杠),miss 时回退全表按归一化匹配。
1395
+ row = storeRowByPath(db, 'undo', 'path, content, result_content, bom, ending', rawPath, want)
1396
+ } finally { try { db.close() } catch (e) {} }
1397
+ }
1398
+ } catch (e) { invalidateStoreCache(entry.projRoot); row = null }
1399
+ if (!row) return { ok: false, error: bi('该文件没有可撤销的编辑记录,撤销会被跳过', 'No undo history for this file; the undo will be skipped') }
1400
+ try {
1401
+ const curText = await fsService.readText(target)
1402
+ const after = row.content === null || row.content === undefined ? '' : String(row.content)
1403
+ if (overMaxChars(curText, after)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1404
+ // 与 better-edit 的校验口径对齐:better-edit 用 undo 行的 bom/ending 做字节级精确比较,
1405
+ // 撤销校验含 BOM 与行尾。这里先做归一化比较,再对 BOM/行尾做敏感复核——
1406
+ // 仅行尾(CRLF↔LF)或 BOM 差异的覆盖在 better-edit 会返回 E_UNDO_STALE,预览同步按 stale 处理。
1407
+ const resultContent = row.result_content === null || row.result_content === undefined ? '' : String(row.result_content)
1408
+ const normTxt = (s) => normEol(String(s || '').replace(/^\uFEFF/, ''))
1409
+ if (normTxt(curText) !== normTxt(resultContent)) {
1410
+ return { ok: false, error: bi('文件在该次编辑后已被改动,撤销不会执行(无变化)', 'File changed after that edit; the undo will not run (no change)') }
1411
+ }
1412
+ const undoBom = row.bom === '\uFEFF' ? '\uFEFF' : ''
1413
+ const undoEnding = row.ending === '\r\n' || row.ending === '\r' ? row.ending : '\n'
1414
+ // 磁盘真实 BOM 状态:readText 经 TextDecoder 解码会剥离前导 BOM,故直接读前 3 字节比对
1415
+ // EF BB BF;读不到时回退到「stat.size 与文本字节数比对」的旧判据,与 undo.bom 不一致视为 stale
1416
+ if (typeof info.size === 'number') {
1417
+ let diskHasBom = false
1418
+ try {
1419
+ // readBytes 的 maxBytes 是「整文件上限」(超限直接抛 FS_TOO_LARGE),
1420
+ // 取文件头要用区间读取;不支持时由 catch 回退到 size 判据
1421
+ const head = (fsService.readByteRange && target) ? await fsService.readByteRange(target, { offset: 0, length: 3 }, undefined) : null
1422
+ diskHasBom = !!(head && head.length >= 3 && head[0] === 0xEF && head[1] === 0xBB && head[2] === 0xBF)
1423
+ } catch (e) {
1424
+ diskHasBom = info.size === Buffer.byteLength(curText, 'utf8') + 3
1425
+ }
1426
+ const wantBom = undoBom !== ''
1427
+ if (diskHasBom !== wantBom) {
1428
+ return { ok: false, error: bi('文件 BOM 在该次编辑后已被改动,撤销不会执行(无变化)', 'File BOM changed after that edit; the undo will not run (no change)') }
1429
+ }
1430
+ }
1431
+ // 内容与行尾精确比较:curText 保留原始 CRLF;resultContent 为 \n 规范化存储,按 undo.ending 还原
1432
+ // undoEnding 为 '\n'(LF 文件,最常见)时该 replace 是恒等变换,短路避免整串副本
1433
+ const exactResult = undoEnding === '\n' ? String(resultContent) : String(resultContent).replace(/\n/g, undoEnding)
1434
+ if (curText !== exactResult) {
1435
+ return { ok: false, error: bi('文件行尾/编码在该次编辑后已被改动,撤销不会执行(无变化)', 'File line endings or encoding changed after that edit; the undo will not run (no change)') }
1436
+ }
1437
+ const oldLines = splitDiffLines(curText)
1438
+ const newLines = splitDiffLines(after)
1439
+ const p = commonPrefixLen(oldLines, newLines)
1440
+ const s = commonSuffixLen(oldLines, newLines, p)
1441
+ return windowedDiffPayload(fp, oldLines, newLines, p + 1, oldLines.length - s - p, newLines.length - s - p, 200)
1442
+ } catch (e) {
1443
+ return { ok: false, error: readFail(e) }
1444
+ }
1445
+ }
1446
+ // edits 条目统一解析:元组 [remove_from, remove_to, replacement_text] 或对象 {remove_from, remove_to, replacement_text}
1447
+ function editTuple(raw) {
1448
+ if (Array.isArray(raw) && raw.length >= 3) return { remove_from: raw[0], remove_to: raw[1], replacement_text: raw[2] }
1449
+ return raw || {}
1450
+ }
1451
+ // replacement_text 统一提取(不含行尾归一化,供「过大」上限等上界用途)
1452
+ function editReplacement(raw) {
1453
+ const t = editTuple(raw)
1454
+ return typeof t.replacement_text === 'string' ? t.replacement_text : ''
1455
+ }
1456
+ // replacement_text 统一提取(行尾归一化,供补丁应用/窗口 diff 用途)
1457
+ function editReplNorm(raw) {
1458
+ const t = editTuple(raw)
1459
+ return (t && typeof t.replacement_text === 'string') ? t.replacement_text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''
1460
+ }
1461
+
1462
+
1463
+ // 统一「resolve→stat→存在/类型/size」预检(不 readText):readTargetChecked、undo 预检与
1464
+ // 图片详情(skipSizeCheck + 自定义「不存在」文案)共用,
1465
+ // 避免预检检查与文案多份独立演化(曾因此出现 size 预检形式漂移)
1466
+ // 配置目标存在性判定(守卫方向敏感):stat 失败按「存在」处理——
1467
+ // 宁可拒绝写入并提示,也不静默覆盖已有配置
1468
+ async function configExists(fsService, p) {
1469
+ try { return (await fsService.stat(p)) !== undefined } catch (e) { return true }
1470
+ }
1471
+
1472
+ // opts.skipSizeCheck:图片详情另有自己的体积上限(IMAGE_MAX_BYTES),不套用 DIFF_MAX_CHARS 预检
1473
+ // opts.notFoundZh/notFoundEn:调用方覆盖「文件不存在」文案(图片详情用「图片不存在」)
1474
+ async function statTargetChecked(fp, projRoot, fsService, opts) {
1475
+ const o = opts || {}
1476
+ try {
1477
+ const target = await fsService.resolve(resolveArgPath(fp, projRoot))
1478
+ const info = await fsService.stat(target)
1479
+ if (info === undefined) return { ok: false, error: bi(o.notFoundZh || '文件不存在', o.notFoundEn || 'File not found') }
1480
+ if (info.type !== 'file') return { ok: false, error: bi('不是普通文件', 'Not a regular file') }
1481
+ if (!o.skipSizeCheck && fileTooLarge(info, DIFF_MAX_CHARS)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1482
+ return { ok: true, target, info }
1483
+ } catch (e) {
1484
+ return { ok: false, error: readFail(e) }
1485
+ }
1486
+ }
1487
+
1488
+ // 统一「预检 + readText」:返回 {ok:true,target,info,text} 或 {ok:false,error}。
1489
+ // 写分支各读盘入口共用;preText 非空时直接复用(调用方已完成预检读盘,如 str_replace 唯一性检查),避免双读盘
1490
+ async function readTargetChecked(fp, projRoot, fsService, preText) {
1491
+ if (preText !== null && preText !== undefined) return { ok: true, target: null, info: null, text: preText }
1492
+ const st = await statTargetChecked(fp, projRoot, fsService)
1493
+ if (!st.ok) return st
1494
+ try {
1495
+ const text = await fsService.readText(st.target)
1496
+ return { ok: true, target: st.target, info: st.info, text }
1497
+ } catch (e) {
1498
+ return { ok: false, error: readFail(e) }
1499
+ }
1500
+ }
1501
+
981
1502
  // 按审批 entry 生成对比数据(/permgate/file-diff 路由用;失败返回 {ok:false,error},不支持返回 null)
1503
+ // 图片详情数据:格式/尺寸 + 缩略图(data URL)。失败一律 {ok:false,error},客户端显示错误文本;
1504
+ // 超过体积上限则只给格式/尺寸并标记 tooLarge,不返回图片本体。
1505
+ async function buildImageDiffData(entry, fsService, fp) {
1506
+ if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
1507
+ // 预检复用 statTargetChecked:跳过文本 diff 的字符数上限(图片另有 IMAGE_MAX_BYTES)
1508
+ const st = await statTargetChecked(fp, entry.projRoot, fsService, { skipSizeCheck: true, notFoundZh: '图片不存在', notFoundEn: 'Image not found' })
1509
+ if (!st.ok) return { ok: false, error: st.error }
1510
+ const target = st.target
1511
+ const size = Number(st.info.size) || 0
1512
+ let head = null
1513
+ let whole = null
1514
+ try {
1515
+ // 体积在预览预算内:一次性整读,既用于嗅探也直接用于内联,避免同一文件被读两遍
1516
+ // (照片常见的 64KB~2MB 区间原本会读两次);体积超预算或读不到 size 时只读头部窗口,
1517
+ // 先确认格式与尺寸,再决定要不要整读。
1518
+ if (size > 0 && size <= IMAGE_MAX_BYTES) {
1519
+ whole = await fsService.readBytes(target, undefined, IMAGE_MAX_BYTES)
1520
+ head = whole.subarray(0, Math.min(whole.length, IMAGE_HEAD_BYTES))
1521
+ } else {
1522
+ const len = Math.max(16, Math.min(size || IMAGE_HEAD_BYTES, IMAGE_HEAD_BYTES))
1523
+ head = await fsService.readByteRange(target, { offset: 0, length: len }, undefined)
1524
+ }
1525
+ } catch (e) {
1526
+ return { ok: false, error: readFail(e) }
1527
+ }
1528
+ // 已整读时直接用整份缓冲嗅探:JPEG 的 SOF 段可能落在 64KB 头部窗口之外,
1529
+ // 而已持有全部字节(≤ IMAGE_MAX_BYTES),不必因为窗口取不到尺寸就放弃预览。
1530
+ const meta = sniffImage(whole || head)
1531
+ if (!meta) return { ok: false, error: bi('无法预览:不是可识别的图片(仅支持 PNG/JPEG/WebP/GIF)', 'Cannot preview: not a recognized image (PNG/JPEG/WebP/GIF)') }
1532
+ const out = { ok: true, kind: 'image', file: fp, format: meta.format, mime: IMAGE_MIME[meta.format] || '', width: meta.width, height: meta.height, size }
1533
+ // 尺寸未知(JPEG 的 SOF 段被 >64KB 的元数据段推到头部窗口之外、畸形段、WebP 未知 chunk 等)时,
1534
+ // 像素与边长闸无从判断,一律不内联本体,否则「弹窗解码预算」会被 2MB 以内的高压缩比图绕过。
1535
+ const sizeKnown = !!(meta.width && meta.height)
1536
+ const pixelOver = !!(sizeKnown && (meta.width * meta.height > IMAGE_MAX_PIXELS || meta.width > IMAGE_MAX_DIM || meta.height > IMAGE_MAX_DIM))
1537
+ if (!sizeKnown) { out.sizeUnknown = true; return out }
1538
+ if (size > IMAGE_MAX_BYTES || pixelOver) { out.tooLarge = true; if (size > IMAGE_MAX_BYTES) out.limit = IMAGE_MAX_BYTES; return out }
1539
+ try {
1540
+ // 预算内已整读过就直接复用;否则(头部窗口内已判合规而 size 未知)再整读一次
1541
+ const bytes = whole || await fsService.readBytes(target, undefined, IMAGE_MAX_BYTES)
1542
+ out.dataUrl = 'data:' + (out.mime || 'application/octet-stream') + ';base64,' + Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
1543
+ } catch (e) {
1544
+ // 不带图片本体,客户端按「暂无可用的缩略图」提示;记录真实错误,避免失败原因不可见
1545
+ console.error('[permgate] image preview error:', e)
1546
+ }
1547
+ return out
1548
+ }
1549
+
982
1550
  async function buildFileDiffData(entry, fsService) {
983
1551
  const name = entry.tool
984
1552
  const args = parseEntryArgs(entry)
985
1553
  const fp = pathArg(args)
986
- if (FILE_READ_TOOLS[name]) {
1554
+ if (isUndo(name)) return await buildUndoDiffData(entry, fsService, fp)
1555
+ if (isFileImage(name)) return await buildImageDiffData(entry, fsService, fp)
1556
+ if (isFileRead(name, args)) {
987
1557
  if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
1558
+ // 与图片/撤销详情共用预检单点;read 走窗口化读取,不需要 DIFF_MAX_CHARS 体积闸
1559
+ const st = await statTargetChecked(fp, entry.projRoot, fsService, { skipSizeCheck: true })
1560
+ if (!st.ok) return st
988
1561
  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') }
1562
+ const target = st.target
993
1563
  // 窗口化读取:只取 offset/limit 附近(前后各 W 行)的内容,流式消费到窗口末尾即停,
994
1564
  // 不整读大文件;末尾省略行数未知,由客户端显示通用提示。
995
1565
  // 资源上限:offset/limit 来自 agent 工具参数(不可信),且文件中可能存在无换行的
@@ -999,8 +1569,23 @@ export default {
999
1569
  const MAX_LIMIT = 4096
1000
1570
  const MAX_BYTES = 262144
1001
1571
  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)
1572
+ // str_replace_editor view view_range([start, end],1 基,end=-1 表示到文件尾);
1573
+ // 预览据此换算 offset/limit,否则展示区域与实际读取不符(恒为文件开头)
1574
+ let vOffset = args.offset
1575
+ let vLimit = args.limit
1576
+ if (name === 'str_replace_editor' && Array.isArray(args.view_range) && args.view_range.length >= 2) {
1577
+ const vs = Number(args.view_range[0])
1578
+ const ve = Number(args.view_range[1])
1579
+ if (Number.isFinite(vs) && vs > 0) {
1580
+ vOffset = vs
1581
+ // end=-1 表示到文件尾(受 MAX_LIMIT 截断);end<start 等非法组合会被工具报错,预览同样提示失败
1582
+ if (Number.isFinite(ve) && ve === -1) vLimit = MAX_LIMIT
1583
+ else if (Number.isFinite(ve) && ve >= vs) vLimit = ve - vs + 1
1584
+ else return { ok: false, error: bi('view_range 不合法,该命令将失败(无改动可预览)', 'Invalid view_range; the command will fail (no change to preview)') }
1585
+ }
1586
+ }
1587
+ const offset = Number.isFinite(vOffset) && vOffset > 0 ? Math.floor(vOffset) : 1
1588
+ const limit = Math.min(Number.isFinite(vLimit) && vLimit > 0 ? Math.floor(vLimit) : 200, MAX_LIMIT)
1004
1589
  const winStart = Math.max(1, offset - W)
1005
1590
  const winEnd = offset + limit - 1 + W
1006
1591
  const out = []
@@ -1050,196 +1635,240 @@ export default {
1050
1635
  } catch (e) {
1051
1636
  // 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
1052
1637
  // 返回双语对象,由路由侧 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 } }
1638
+ return { ok: false, error: readFail(e) }
1055
1639
  }
1056
1640
  }
1057
- if (FILE_WRITE_TOOLS[name]) {
1641
+ // str_replace_editor 的 undo_edit 命令不写盘(better-edit 直接抛 E_UNSUPPORTED),无改动可预览
1642
+ if (name === 'str_replace_editor' && sreCommand(args) === 'undo_edit') {
1643
+ return { ok: false, error: bi('该命令没有可预览的改动', 'This command has no previewable change') }
1644
+ }
1645
+ if (isFileWrite(name, args)) {
1646
+ // str_replace 唯一性检查已读盘时缓存文本,供下方 edit 分支复用,避免同一文件双读盘
1647
+ let sreText = null
1058
1648
  if (!fp) return { ok: false, error: bi('缺少文件路径', 'Missing file path') }
1059
- if (name === 'edit') {
1649
+ // str_replace_editor 参数适配:折算成 write/edit 的等价形式复用下面的成熟路径
1650
+ // (create→write 全文;str_replace/insert→edit 补丁;undo_edit 无改动内容可预览)
1651
+ let tool = name
1652
+ // sreKind:本次预览的 str_replace_editor 子命令(非 sre 工具时为空串),
1653
+ // 供下方 write 分支特判 create 复用,避免同一命令在两处各判一次
1654
+ let sreKind = ''
1655
+ // str_replace_editor 只有被 shadow 覆盖时 insert_line 才是 1 基/插到该行之前;
1656
+ // 内核在审批发起时判定并存入 entry(entry 生命周期内不变);resolveEditorKernel 恒返回
1657
+ // builtin|shadow,故这里只在异常数据下用 'builtin' 兜底,不再重复做一次探测
1658
+ const kernel = entry.editorKernel || 'builtin'
1659
+ if (name === 'str_replace_editor') {
1660
+ const cmd = sreCommand(args)
1661
+ sreKind = cmd
1662
+ if (cmd === 'create') {
1663
+ // create 拒绝覆盖已存在文件(E_FILE_EXISTS,不写盘):已存在时不能生成覆盖 diff。
1664
+ // 存在性检查由下方 write 分支的 stat 承担(此处不再重复 resolve+stat)
1665
+ tool = 'write'
1666
+ args.content = typeof args.file_text === 'string' ? args.file_text : ''
1667
+ } else if (cmd === 'str_replace') {
1668
+ // 两种内核在 old_str 多次匹配时都拒绝写盘(内置抛 FS_AMBIGUOUS_EDIT、shadow 同要求唯一匹配),
1669
+ // 故不做内核分叉:一律按「不唯一即失败」提示,避免展示一次永远不会发生的替换
1670
+ const oldStr = typeof args.old_str === 'string' ? args.old_str : ''
1671
+ if (oldStr) {
1672
+ const rd = await readTargetChecked(fp, entry.projRoot, fsService)
1673
+ if (!rd.ok) return rd
1674
+ let count = 0
1675
+ let at = 0
1676
+ while (count < 2 && (at = rd.text.indexOf(oldStr, at)) !== -1) { count++; at += oldStr.length }
1677
+ if (count > 1) {
1678
+ return { ok: false, error: bi('old_str 出现多次,该命令将失败(无改动可预览)', 'old_str occurs multiple times; the command will fail (no change to preview)') }
1679
+ }
1680
+ sreText = rd.text
1681
+ }
1682
+ tool = 'edit'
1683
+ args.old_string = oldStr
1684
+ args.new_string = typeof args.new_str === 'string' ? args.new_str : ''
1685
+ } else if (cmd === 'insert') {
1686
+ // insert 的 insert_line 语义随内核相反:DSH 内置 0 基、插到该行之后(官方语义);
1687
+ // dsh-better-edit shadow 1 基、插到该行之前。按审批发起时判定的内核解释,
1688
+ // 否则会把插入位置画到错误的地方。
1689
+ // 不能折算成 old_string='' 的 edit —— 那会让 rawIdx 恒为 -1,插入位置被伪造成
1690
+ // 「文件开头第 1 行」。这里读盘后按真实插入点生成窗口 diff,行号与实际执行一致。
1691
+ // null/''/false 等占位值不得折算为 0:内置取参把 null 视为未提供并报 required,
1692
+ // 折算成 0 会被当成合法的 0 基位置,预览出一次必定失败的插入
1693
+ const rawInsLine = args.insert_line
1694
+ const insLine = (rawInsLine === null || rawInsLine === undefined || rawInsLine === '' || rawInsLine === false) ? NaN : Number(rawInsLine)
1695
+ const insText = typeof args.new_str === 'string' ? args.new_str : ''
1696
+ const rd = await readTargetChecked(fp, entry.projRoot, fsService)
1697
+ if (!rd.ok) return rd
1698
+ const fileText = rd.text
1699
+ if (overMaxChars(fileText, insText)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1700
+ const addedLines = splitDiffLines(insText)
1701
+ if (kernel === 'builtin') {
1702
+ // 官方语义:insert_line 0 基,插入到该 index 之前(= 第 insert_line 行之后),范围 [0, 行数]
1703
+ const oldLines = splitDiffLines(fileText)
1704
+ return previewInsert(fp, oldLines, addedLines, insLine, oldLines.length)
1705
+ }
1706
+ // shadow:insert_line 1 基、插入到该行之前;空文件行数组为 []、上限「去尾换行行数 + 1」
1707
+ const oldLines = fileText.length === 0 ? [] : splitDiffLines(fileText)
1708
+ const maxInsert = fileText.length === 0 ? 1 : (fileText.endsWith('\n') ? oldLines.length : oldLines.length + 1)
1709
+ return previewInsert(fp, oldLines, addedLines, insLine - 1, maxInsert - 1)
1710
+ }
1711
+ }
1712
+ if (tool === 'edit') {
1060
1713
  // dsh-better-edit 兼容:{path, edits:[[remove_from,remove_to,replacement_text],...]} hash 锚点格式。
1061
1714
  // 与旧格式(old_string/new_string)互斥,优先识别 edits 数组。
1062
1715
  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')
1716
+ const rd = await readTargetChecked(fp, entry.projRoot, fsService)
1717
+ if (!rd.ok) return rd
1718
+ const fileText = rd.text
1719
+ const target = rd.target
1720
+ // 「文件过大」兜底:磁盘全文 + 全部 replacement_text 总长(agent 可控,必须计入上限)
1721
+ let replTotal = 0
1722
+ for (const raw of args.edits) { replTotal += editReplacement(raw).length }
1723
+ if (overMaxChars(fileText, replTotal)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1724
+ // 1) 优先 store 快照;2) 快照过期则从磁盘内容重算(xxh32 复刻);3) 都失败再降级
1725
+ let hashes = await betterEditHashesFor(entry.projRoot, target, fp)
1726
+ if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
1727
+ hashes = await betterEditHashesFromDisk(fileText)
1728
+ }
1729
+ if (!hashes || !Array.isArray(hashes) || hashes.length === 0) {
1730
+ // 无任何 hash 来源:无法映射锚点,退回补丁意图展示(至少显示替换文本)
1731
+ const intent = args.edits.map(editReplacement).join('\n')
1732
+ return diffPayloadOrFallback(fp, '', intent, 'modified')
1733
+ }
1734
+ const applied = applyBetterEdits(fileText, args.edits, hashes)
1735
+ if (applied === null) {
1736
+ // 锚点失效或 store 与磁盘不一致:退回补丁意图展示
1737
+ const intent = args.edits.map(editReplacement).join('\n')
1738
+ return diffPayloadOrFallback(fp, '', intent, 'modified')
1739
+ }
1740
+ // 分窗口 diff:把相距较远的 edits 分成多组(相邻间隔 2W 同组),
1741
+ // 每组独立生成一个小窗口 diff 再拼接——避免单个大窗口把中间大段未变内容
1742
+ // 算成 +N/-N 假变更(如 +300 -300)。
1743
+ // 关键:new 侧不从「整文件应用后按行号切片」——行数变化(如 1 行换 52 行)会让
1744
+ // new 侧整体偏移,窗口尾部与 old 错位,把大量未变行误判为变更(+52/-52、+342/-342
1745
+ // 等假象)。改为以 old 窗口行为基底、仅在该窗口内应用本组 edits(跟踪 offset),
1746
+ // 使 old/new 覆盖同一内容区域、行号天然对齐。
1747
+ const W = 200
1748
+ const oldLines = splitDiffLines(String(fileText).replace(/\r\n/g, '\n').replace(/\r/g, '\n'))
1749
+ // 每个 edit 的原始行区间(0 基)
1750
+ const editRanges = args.edits.map((raw) => {
1751
+ const e = editTuple(raw)
1752
+ const a = betterEditAnchor(e && e.remove_from)
1753
+ const b = betterEditAnchor(e && e.remove_to)
1754
+ let s = -1, t = -1
1755
+ if (a && hashes) { const i = hashes.indexOf(a); if (i >= 0) s = i }
1756
+ if (b && hashes) { const i = hashes.indexOf(b); if (i >= 0) t = i }
1757
+ if (s < 0 && t >= 0) s = t
1758
+ if (t < 0 && s >= 0) t = s
1759
+ return { s, t }
1760
+ })
1761
+ // 分组:按起始行排序,间隔 > 2W 开新组
1762
+ const order = args.edits.map((_, i) => i).sort((x, y) => editRanges[x].s - editRanges[y].s)
1763
+ const groups = []
1764
+ let cur = null
1765
+ for (const i of order) {
1766
+ const line = editRanges[i].s
1767
+ if (line < 0) continue
1768
+ if (!cur || line - cur.max > 2 * W) {
1769
+ cur = { min: line, max: line, indices: [i] }
1770
+ groups.push(cur)
1771
+ } else {
1772
+ cur.max = Math.max(cur.max, line)
1773
+ cur.indices.push(i)
1091
1774
  }
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
- }
1775
+ }
1776
+ const allOps = []
1777
+ let totalAdded = 0
1778
+ let totalRemoved = 0
1779
+ for (const g of groups) {
1780
+ let gMin0 = Infinity, gMax0 = -Infinity
1781
+ for (const i of g.indices) {
1782
+ const r = editRanges[i]
1783
+ if (r.s < gMin0) gMin0 = r.s
1784
+ if (r.t > gMax0) gMax0 = r.t
1127
1785
  }
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
- }
1786
+ if (gMin0 === Infinity) continue
1787
+ const gMin = Math.max(1, gMin0 + 1 - W)
1788
+ const gOldEnd = Math.min(oldLines.length, gMax0 + 1 + W)
1789
+ // 新侧:以 old 窗口为基底,仅应用本组 edits,跟踪 offset
1790
+ const local = oldLines.slice(gMin - 1, gOldEnd)
1791
+ let off = 0
1792
+ let resolved = true
1793
+ for (const i of g.indices) {
1794
+ const r = editRanges[i]
1795
+ const raw = args.edits[i]
1796
+ const repl = editReplNorm(raw)
1797
+ const ls = r.s - (gMin - 1) + off
1798
+ const lt = r.t - (gMin - 1) + off
1799
+ if (ls < 0 || lt < ls || lt > local.length) { resolved = false; break }
1800
+ const replLines = repl === '' ? [] : repl.split('\n')
1801
+ local.splice(ls, lt - ls + 1, ...replLines)
1802
+ off += replLines.length - (lt - ls + 1)
1170
1803
  }
1171
- if (allOps.length === 0 && totalAdded === 0 && totalRemoved === 0) {
1172
- return diffPayloadOrFallback(fp, oldLines.join('\n'), splitDiffLines(applied.text).join('\n'), 'modified', 1)
1804
+ if (!resolved) continue
1805
+ const oldWin = oldLines.slice(gMin - 1, gOldEnd).join('\n')
1806
+ const newWin = local.join('\n')
1807
+ const p = diffPayloadOrFallback(fp, oldWin, newWin, 'modified', gMin)
1808
+ if (!p || !p.ok) continue
1809
+ if (p.fallback) return p
1810
+ totalAdded += p.added || 0
1811
+ totalRemoved += p.removed || 0
1812
+ if (Array.isArray(p.ops)) {
1813
+ for (const op of p.ops) allOps.push(op)
1814
+ } else if (p.lines) {
1815
+ return p
1173
1816
  }
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
1817
  }
1818
+ if (allOps.length === 0 && totalAdded === 0 && totalRemoved === 0) {
1819
+ return diffPayloadOrFallback(fp, oldLines.join('\n'), splitDiffLines(applied.text).join('\n'), 'modified', 1)
1820
+ }
1821
+ return { ok: true, kind: 'modified', file: fp, added: totalAdded, removed: totalRemoved, ops: allOps, truncated: 0, grouped: true }
1179
1822
  }
1180
1823
  const oldText = typeof args.old_string === 'string' ? args.old_string : ''
1181
1824
  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') }
1825
+ if (overMaxChars(oldText, newText)) return { ok: false, error: bi('内容过大,无法生成对比', 'Content too large to compare') }
1183
1826
  // 关键:edit 是补丁式,仅对比 old_string/new_string 会丢失文件上下文(抽屉只会显示
1184
1827
  // 补丁那几行)。改为读取磁盘当前内容、应用补丁后,取改动前后各 W 行的窗口做 diff——
1185
1828
  // 行号从真实位置起算,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')
1829
+ const rd = await readTargetChecked(fp, entry.projRoot, fsService, sreText)
1830
+ if (!rd.ok) return rd
1831
+ const fileText = rd.text
1832
+ if (overMaxChars(fileText, newText)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1833
+ // 行尾处理:磁盘文件可能是 CRLF/CR 而工具参数为 LF。优先按原始文本字面匹配
1834
+ // (预览与实际 edit 结果一致);字面匹配失败且磁盘含 CR/CRLF 时,退而按 \n
1835
+ // 归一化匹配构建预览窗口(与 splitDiffLines 同一归一化规则),并在 payload 上
1836
+ // 标记 eolNormalized——此时预览仅为意图展示:实际 edit 按原始字节字面匹配
1837
+ // 仍可能失败,由客户端提示,避免审批者基于"假成功"预览做决策。
1838
+ const rawIdx = oldText ? fileText.indexOf(oldText) : -1
1839
+ // oldNorm/newNorm 为补丁级小字符串,供行数统计与归一化预览共用;fileNorm
1840
+ // 为全文件副本,仅在字面匹配失败且文件确实含 \r 时才构建(避免常见路径对
1841
+ // 最多 1MB 文件做两趟全量 replace 扫描)。
1842
+ const oldNorm = normEol(oldText)
1843
+ const newNorm = normEol(newText)
1844
+ let eolNormalized = false
1845
+ let idx = rawIdx
1846
+ let baseText = fileText
1847
+ let oldLen = oldText.length
1848
+ if (rawIdx === -1 && oldNorm && fileText.indexOf('\r') !== -1) {
1849
+ const fileNorm = normEol(fileText)
1850
+ const normIdx = fileNorm.indexOf(oldNorm)
1851
+ if (normIdx !== -1) {
1852
+ idx = normIdx
1853
+ baseText = fileNorm
1854
+ oldLen = oldNorm.length
1855
+ eolNormalized = true
1222
1856
  }
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
1857
  }
1858
+ if (idx === -1) {
1859
+ // 磁盘内容已与提案脱节(旧文本未找到):退回补丁级对比,至少展示改动意图
1860
+ return diffPayloadOrFallback(fp, oldText, newText, 'modified')
1861
+ }
1862
+ const applied = baseText.slice(0, idx) + (eolNormalized ? newNorm : newText) + baseText.slice(idx + oldLen)
1863
+ const oldLines = splitDiffLines(baseText)
1864
+ const newLines = splitDiffLines(applied)
1865
+ const lineStart = 1 + countNewlines(baseText, idx)
1866
+ const oldCnt = splitDiffLines(oldNorm).length
1867
+ const newCnt = splitDiffLines(newNorm).length
1868
+ const payload = windowedDiffPayload(fp, oldLines, newLines, lineStart, oldCnt, newCnt, 200)
1869
+ // diffPayloadOrFallback 恒返回 ok:true 的 payload(失败时返回 fallback 视图而非 ok:false)
1870
+ if (eolNormalized) payload.eolNormalized = true
1871
+ return payload
1243
1872
  }
1244
1873
  const content = typeof args.content === 'string' ? args.content : ''
1245
1874
  if (!content || content.length > DIFF_MAX_CHARS) return { ok: false, error: bi('内容缺失或过大', 'Content missing or too large') }
@@ -1247,16 +1876,19 @@ export default {
1247
1876
  const target = await fsService.resolve(resolveArgPath(fp, entry.projRoot))
1248
1877
  const info = await fsService.stat(target)
1249
1878
  if (info === undefined) return newFilePayload(fp, content)
1879
+ // create 拒绝覆盖已存在文件:write 分支已 stat,此处特判(避免 create 分支重复 resolve+stat)
1880
+ if (sreKind === 'create') {
1881
+ return { ok: false, error: bi('create 不会覆盖已存在的文件(该命令将失败),无改动可预览', 'create will fail: file already exists; no change to preview') }
1882
+ }
1250
1883
  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') }
1884
+ if (fileTooLarge(info, DIFF_MAX_CHARS)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1252
1885
  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') }
1886
+ if (overMaxChars(oldText, content)) return { ok: false, error: bi('文件过大,无法生成对比', 'File too large to compare') }
1254
1887
  return diffPayloadOrFallback(fp, oldText, content, 'modified')
1255
1888
  } catch (e) {
1256
1889
  // 注意:不能与字符串直接拼接(bi() 返回 {zh,en} 对象,+ 会得到 "[object Object]");
1257
1890
  // 返回双语对象,由路由侧 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 } }
1891
+ return { ok: false, error: readFail(e) }
1260
1892
  }
1261
1893
  }
1262
1894
  return null
@@ -1271,7 +1903,18 @@ export default {
1271
1903
  if (!r) return false
1272
1904
  const s = norm(p)
1273
1905
  const abs = (s.indexOf('/') === 0 || /^[a-zA-Z]:/.test(s)) ? s : r + '/' + s
1274
- return abs.toLowerCase().indexOf(r.toLowerCase()) !== 0
1906
+ // 盘根 'G:'(norm 剥掉尾斜杠)补回根斜杠:pathResolve('G:') 会落到 cwd 而非盘根;
1907
+ // UNC 根 pathResolve 输出带尾部分隔符,前缀判断需按「rr 已以分隔符结尾」分支处理
1908
+ const fixRoot = (v) => pathResolve(/^[a-zA-Z]:$/.test(v) ? v + '/' : v)
1909
+ const ra = fixRoot(abs)
1910
+ const rr = fixRoot(r)
1911
+ const lowerRa = ra.toLowerCase()
1912
+ const lowerRr = rr.toLowerCase()
1913
+ // 根本身(含盘根/UNC 根,rr 可能以分隔符结尾)为区内;前缀比较带分隔符边界
1914
+ if (lowerRa === lowerRr) return false
1915
+ const rrEndSep = lowerRr.endsWith('/') || lowerRr.endsWith('\\')
1916
+ if (rrEndSep) return lowerRa.indexOf(lowerRr) !== 0
1917
+ return lowerRa.indexOf(lowerRr + '/') !== 0 && lowerRa.indexOf(lowerRr + '\\') !== 0
1275
1918
  }
1276
1919
 
1277
1920
  function callKey(name, args) {
@@ -1298,7 +1941,11 @@ export default {
1298
1941
  for (const k of Object.keys(gMap)) {
1299
1942
  if (matchGlob(k, name)) return { action: gMap[k] }
1300
1943
  }
1301
- return null
1944
+ // 预设工具的默认动作同样是「决策默认」:配置里缺席(升级前生成的老配置不含新键)时按
1945
+ // QUICK_DEFAULTS 裁决,与面板显示走同一条链(项目键 → 全局键 → 预设默认 → 兜底),
1946
+ // 新老配置行为一致;显式配置项与 migrateOld 的 locked deny 仍优先于此。
1947
+ const def = Object.prototype.hasOwnProperty.call(QUICK_DEFAULTS, name) ? QUICK_DEFAULTS[name] : null
1948
+ return def ? { action: def, isDefault: true } : null
1302
1949
  }
1303
1950
 
1304
1951
  function textOfBlock(b) {
@@ -1353,7 +2000,9 @@ export default {
1353
2000
  }
1354
2001
  if (d.kind === 'path' && v) {
1355
2002
  if (d.cat === 'read') return bi('读取文件 ' + v, 'Read file ' + v)
2003
+ if (d.cat === 'image') return bi('读取图片 ' + v, 'Read image ' + v)
1356
2004
  if (d.cat === 'edit') return bi('写入/修改文件 ' + v, 'Write/modify file ' + v)
2005
+ if (d.cat === 'undo') return bi('撤销操作(恢复上次编辑前的内容):' + v, 'Undo edit (revert last edit): ' + v)
1357
2006
  return bi('访问路径 ' + v, 'Access path ' + v)
1358
2007
  }
1359
2008
  if (d.cat === 'doomloop') return bi('重复操作拦截:' + t + ' 连续多次相同调用,疑似循环', 'Doom Loop: ' + t + ' repeated identically, possible loop')
@@ -1383,19 +2032,34 @@ export default {
1383
2032
  const t = (zh, en) => (lang === 'en' ? en : zh)
1384
2033
  try {
1385
2034
  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]) {
2035
+ const fp = pathArg(args)
2036
+ if (isUndo(name)) {
2037
+ push(t('撤销', 'Undo'), fp ? baseName(fp) : '', fp ? { path: fp } : undefined)
2038
+ if (fp) push(t('路径', 'Path'), fp, { path: fp })
2039
+ } else if (isFileRead(name, args)) {
1388
2040
  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)
2041
+ push(t('读取', 'Read'), target ? baseName(target) : '', fp ? { path: fp } : undefined)
2042
+ if (fp) push(t('路径', 'Path'), fp, { path: fp })
1393
2043
  if (args.offset !== undefined) push(t('偏移', 'Offset'), args.offset)
1394
2044
  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)
2045
+ } else if (isFileImage(name)) {
2046
+ // 图片不做文本预览,路径也不可点击(「打开文件」白名单只含文本/文档类,点了也打不开)
2047
+ const target = fp || args.path || ''
2048
+ push(t('读取图片', 'Read image'), target ? baseName(target) : '', undefined)
2049
+ if (fp) push(t('路径', 'Path'), fp, undefined)
2050
+ } else if (isFileWrite(name, args)) {
2051
+ const isSre = name === 'str_replace_editor'
2052
+ const sreCmd = isSre ? sreCommand(args) : ''
2053
+ const wLabel = isSre
2054
+ ? (sreCmd === 'create' ? t('创建', 'Create') : t('编辑', 'Edit'))
2055
+ : (name === 'edit' ? t('修改', 'Edit') : t('写入', 'Write'))
2056
+ push(wLabel, fp ? baseName(fp) : '', fp ? { path: fp } : undefined)
1397
2057
  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 : '')
2058
+ const content = typeof args.content === 'string'
2059
+ ? args.content
2060
+ : (typeof args.new_string === 'string'
2061
+ ? args.new_string
2062
+ : (typeof args.new_str === 'string' ? args.new_str : (typeof args.file_text === 'string' ? args.file_text : '')))
1399
2063
  if (content) push(t('内容', 'Content'), content.length > 140 ? content.slice(0, 140) + '…(共 ' + content.length + ' 字符)' : content)
1400
2064
  } else if (COMMAND_TOOLS[name]) {
1401
2065
  push(t('命令', 'Command'), args.command || '')
@@ -1411,17 +2075,55 @@ export default {
1411
2075
  return lines
1412
2076
  }
1413
2077
 
2078
+ // 工作区外路径类工具的合并矩阵单点(read / image / edit / undo 共用):
2079
+ // 先过「目录访问」闸,再过工具自身分类闸;任一 deny → 拒绝,任一 ask → 询问,否则放行。
2080
+ // cat 取「真正作出决定的那道闸」:它决定弹窗候选(加入例外)写到哪个分类的例外里——
2081
+ // 若只写自身分类而目录闸仍是 ask,用户点「允许」后同一个文件会反复弹窗、且候选会因已存在而消失。
2082
+ // 分类名文案:决定由哪道闸作出,reason 就用哪道闸的名字(否则 cat 已是自身分类、
2083
+ // ruleId 也指向自身分类的例外,文案却写「目录权限」,用户会去改错分类的配置)
2084
+ // deny / ask / allow 三个分支都遵循这一条:cat、前缀、ruleId 三者必须同源。
2085
+ const OUTSIDE_PREFIX = {
2086
+ directory: ['目录权限:', 'Directory permission: '],
2087
+ read: ['读取权限:', 'Read permission: '],
2088
+ image: ['读取图片权限:', 'Read image permission: '],
2089
+ edit: ['编辑权限:', 'Edit permission: '],
2090
+ undo: ['撤销权限:', 'Undo permission: '],
2091
+ }
2092
+
2093
+ function outsideMatrix(catKey, fp) {
2094
+ const d = resolveCategory('directory', fp, 'path')
2095
+ const e = resolveCategory(catKey, fp, 'path')
2096
+ if (d.action === 'deny' || e.action === 'deny') {
2097
+ const src = d.action === 'deny' ? d : e
2098
+ const cat = src === d ? 'directory' : catKey
2099
+ const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
2100
+ return { action: 'deny', src, cat, pz: p[0], pe: p[1] }
2101
+ }
2102
+ if (d.action === 'ask' || e.action === 'ask') {
2103
+ const cat = d.action === 'ask' ? 'directory' : catKey
2104
+ const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
2105
+ return { action: 'ask', src: null, cat, pz: p[0], pe: p[1] }
2106
+ }
2107
+ // allow:与 deny/ask 同口径——由哪道闸的例外实际放行,cat 就跟随哪道闸,
2108
+ // 保证 reason 前缀、ruleId、cat 三者同源(否则文案写「读取图片权限:」
2109
+ // 而括号里的例外 id 属于目录闸,用户会去错分类找一条不存在的规则)。
2110
+ const src = d.ruleId ? d : (e.ruleId ? e : null)
2111
+ const cat = src === d ? 'directory' : catKey
2112
+ const p = OUTSIDE_PREFIX[cat] || OUTSIDE_PREFIX.directory
2113
+ return { action: 'allow', src, cat, pz: p[0], pe: p[1] }
2114
+ }
2115
+
1414
2116
  function decide(exec) {
1415
2117
  const name = exec.name
1416
2118
  const args = exec.arguments
1417
2119
  // deny 例外可携带自定义拒绝原因;有则用自定义文案,无则回退「(例外 id)」标注
1418
2120
  const exReason = (d) => {
1419
- if (d && d.action === 'deny' && d.reason) return d.reason
2121
+ if (d && d.action === 'deny' && d.reason) return '(' + d.reason + ')'
1420
2122
  if (d && d.ruleId) return '(例外 ' + d.ruleId + ')'
1421
2123
  return ''
1422
2124
  }
1423
2125
  const exReasonEn = (d) => {
1424
- if (d && d.action === 'deny' && d.reason) return d.reason
2126
+ if (d && d.action === 'deny' && d.reason) return ' (' + d.reason + ')'
1425
2127
  if (d && d.ruleId) return ' (exception ' + d.ruleId + ')'
1426
2128
  return ''
1427
2129
  }
@@ -1446,41 +2148,67 @@ export default {
1446
2148
  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
2149
  }
1448
2150
  }
1449
- if (FILE_READ_TOOLS[name]) {
2151
+ if (isFileRead(name, args)) {
1450
2152
  const fp = pathArg(args)
1451
2153
  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' }
2154
+ // image/edit/undo 同口径:工作区外先过「目录访问」闸,再过「读取文件」闸。
2155
+ // 只取 directory 的动作,会让 read 分类的 mode 与 deny 例外在跨工作区时完全不生效。
2156
+ const m = outsideMatrix('read', fp)
2157
+ if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝读取工作区外文件 ' + fp + exReason(m.src), m.pe + 'read outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
2158
+ if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '读取工作区外文件 ' + fp + '(需确认)', m.pe + 'read outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
2159
+ return { action: 'allow', reason: bi(m.pz + '读取工作区外文件 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'read outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
1456
2160
  }
1457
2161
  const d = resolveCategory('read', fp, 'path')
1458
2162
  const exZh = exReason(d)
1459
2163
  const exEn = exReasonEn(d)
1460
2164
  return { action: d.action, reason: bi('读取权限' + (fp ? ':' + fp : '') + exZh, 'Read permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'read', value: fp, kind: 'path' }
1461
2165
  }
1462
- if (FILE_WRITE_TOOLS[name]) {
2166
+ // 读图与读文件同一条判定链(工作区外先过「目录访问」闸,再落本分类 + 路径例外),
2167
+ // 只是分类从 read 换成 image:两边的默认动作与例外各自独立配置。
2168
+ if (isFileImage(name)) {
1463
2169
  const fp = pathArg(args)
1464
2170
  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' }
2171
+ // 工作区外读图:directory + image 合并矩阵(不能用 directory 的动作短路,
2172
+ // 否则 image 默认 ask image 的 deny/路径例外在跨工作区场景下全部失效)。
2173
+ const m = outsideMatrix('image', fp)
2174
+ if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝读取工作区外图片 ' + fp + exReason(m.src), m.pe + 'image read outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
2175
+ if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '读取工作区外图片 ' + fp + '(需确认)', m.pe + 'read image outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
2176
+ return { action: 'allow', reason: bi(m.pz + '读取工作区外图片 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'read image outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
2177
+ }
2178
+ const d = resolveCategory('image', fp, 'path')
2179
+ const exZh = exReason(d)
2180
+ const exEn = exReasonEn(d)
2181
+ return { action: d.action, reason: bi('读取图片权限' + (fp ? ':' + fp : '') + exZh, 'Read image permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'image', value: fp, kind: 'path' }
2182
+ }
2183
+ if (isFileWrite(name, args)) {
2184
+ const fp = pathArg(args)
2185
+ if (fp && isOutside(fp, root)) {
2186
+ // 工作区外写入:directory + edit 合并矩阵(与读图/撤销同口径,单点在 outsideMatrix)。
2187
+ const m = outsideMatrix('edit', fp)
2188
+ if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝写入工作区外 ' + fp + exReason(m.src), m.pe + 'write to outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
2189
+ if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '访问工作区外 ' + fp + '(写入需确认)', m.pe + 'access outside workspace ' + fp + ' (write requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
2190
+ return { action: 'allow', reason: bi(m.pz + '访问工作区外 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'access outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
1478
2191
  }
1479
2192
  const d = resolveCategory('edit', fp, 'path')
1480
2193
  const exZh = exReason(d)
1481
2194
  const exEn = exReasonEn(d)
1482
2195
  return { action: d.action, reason: bi('编辑权限' + (fp ? ':' + fp : '') + exZh, 'Edit permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'edit', value: fp, kind: 'path' }
1483
2196
  }
2197
+ // 撤销:会写盘但不是「编辑」——恢复既有内容、不接受调用方提供的新内容,故单列一类(默认询问)。
2198
+ // 与写类一致:工作区外仍先过「目录访问」闸(directory + undo 合并矩阵)。
2199
+ if (isUndo(name)) {
2200
+ const fp = pathArg(args)
2201
+ if (fp && isOutside(fp, root)) {
2202
+ const m = outsideMatrix('undo', fp)
2203
+ if (m.action === 'deny') return { action: 'deny', reason: bi(m.pz + '拒绝撤销工作区外 ' + fp + exReason(m.src), m.pe + 'undo outside workspace denied ' + fp + exReasonEn(m.src)), ruleId: m.src.ruleId, cat: m.cat, value: fp, kind: 'path' }
2204
+ if (m.action === 'ask') return { action: 'ask', reason: bi(m.pz + '撤销工作区外文件 ' + fp + '(需确认)', m.pe + 'undo outside workspace ' + fp + ' (requires confirmation)'), ruleId: null, cat: m.cat, value: fp, kind: 'path' }
2205
+ return { action: 'allow', reason: bi(m.pz + '撤销工作区外文件 ' + fp + (m.src ? exReason(m.src) : ''), m.pe + 'undo outside workspace ' + fp + (m.src ? exReasonEn(m.src) : '')), ruleId: m.src ? m.src.ruleId : null, cat: m.cat, value: fp, kind: 'path' }
2206
+ }
2207
+ const d = resolveCategory('undo', fp, 'path')
2208
+ const exZh = exReason(d)
2209
+ const exEn = exReasonEn(d)
2210
+ return { action: d.action, reason: bi('撤销权限' + (fp ? ':' + fp : '') + exZh, 'Undo permission' + (fp ? ': ' + fp : '') + exEn), ruleId: d.ruleId, cat: 'undo', value: fp, kind: 'path' }
2211
+ }
1484
2212
  if (COMMAND_TOOLS[name]) {
1485
2213
  const cmd = commandArg(args)
1486
2214
  // 命令的所有可识别命令 token 均已命中 allow 例外 → 视为已覆盖,直接放行(不再弹窗)
@@ -1497,8 +2225,15 @@ export default {
1497
2225
  return { action: d.action, reason: bi('启动子代理' + exReason(d), 'Spawn subagent' + exReasonEn(d)), ruleId: d.ruleId, cat: 'subagent', value: null, kind: null }
1498
2226
  }
1499
2227
  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 }
2228
+ if (q) {
2229
+ // 命中预设默认值时区分措辞,避免把「默认动作」说成用户显式设置
2230
+ const label = q.isDefault ? '快捷默认' : '快捷设置'
2231
+ const labelEn = q.isDefault ? 'Quick default' : 'Quick setting'
2232
+ return { action: q.action, reason: bi(label + ':' + name + ' → ' + q.action, labelEn + ': ' + name + ' → ' + q.action), ruleId: null, cat: 'quick', value: name, kind: 'tool' }
2233
+ }
2234
+ const fb = fallbackMode()
2235
+ if (fb === 'allow') return { action: 'allow', reason: bi('未匹配任何规则,放行', 'No rule matched, allowed'), cat: null, value: null, kind: null }
2236
+ return { action: fb, reason: bi('未匹配任何规则,按兜底策略处理:' + fb, 'No rule matched; handled by fallback policy: ' + fb), ruleId: null, cat: 'fallback', value: name, kind: 'tool' }
1502
2237
  }
1503
2238
 
1504
2239
  function recordDecision(d, exec) {
@@ -1510,7 +2245,9 @@ export default {
1510
2245
  const s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
1511
2246
  const idx = s.lastIndexOf('/')
1512
2247
  let dir = idx >= 0 ? s.slice(0, idx) : s
1513
- if (/^[a-zA-Z]:$/.test(dir)) dir += '/'
2248
+ // 盘根(G:)本身就是父目录,直接拼 /*;若补分隔符会得到 G://*,而 norm 不折叠中间双斜杠,
2249
+ // 该 glob 匹配不到任何真实路径,会让「整个目录」候选写出的例外永不生效
2250
+ if (/^[a-zA-Z]:$/.test(dir)) return dir + '/*'
1514
2251
  if (!dir) dir = '/'
1515
2252
  return dir + '/*'
1516
2253
  }
@@ -1646,7 +2383,8 @@ export default {
1646
2383
 
1647
2384
  function buildCandidates(entry) {
1648
2385
  const out = []
1649
- const push = (label, value, kind) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind })
2386
+ const push = (label, value, kind, writes) => out.push({ id: 'c' + Math.random().toString(36).slice(2, 8), label, value, kind, writes: Array.isArray(writes) ? writes : [{ cat: entry.cat, kind, value }] })
2387
+ const t = (zh, en) => (uiLang === 'en' ? en : zh)
1650
2388
  if (entry.kind === 'command' && entry.value) {
1651
2389
  const parts = splitCommandSegments(String(entry.value))
1652
2390
  const seen = {}
@@ -1662,9 +2400,36 @@ export default {
1662
2400
  push(label, val, 'command')
1663
2401
  }
1664
2402
  } else if (entry.kind === 'path' && entry.value) {
1665
- let val = entry.value
1666
- if (entry.cat === 'directory') val = dirGlob(entry.value)
1667
- if (!alreadyInProject(val, 'path', entry.cat)) push(val, val, 'path')
2403
+ // 例外按 glob 匹配:文件路径若含 * 或 ?,写成的例外会比「仅此路径」宽得多
2404
+ // (例如 ** 会匹配整个子树)——这类路径不生成以「文件」为粒度的候选,避免文案与授权范围不符。
2405
+ // 注意:[ ] globToRegExp 里被转义成字面量、不是通配符,故不拦(否则含 [1] 的合法文件名会被误伤)。
2406
+ const hasGlobMeta = (p) => /[*?]/.test(String(p || ''))
2407
+ // 含 .. 段的路径同样不能用来拼目录 glob:glob 只做字符串匹配、不折叠 ..,
2408
+ // 写出的模式会命中解析后与「整个目录」文案完全不同的路径(授权面更大)。
2409
+ const hasParentSeg = (p) => /(^|[\\/])\.\.([\\/]|$)/.test(String(p || ''))
2410
+ const catKey = entry.toolCat && EXC_CATS.indexOf(entry.toolCat) !== -1 ? entry.toolCat : entry.cat
2411
+ const outsideHere = !!(catKey && catKey !== 'directory' && isOutside(entry.value, root))
2412
+ // 候选文案用的分类名:说明这条候选会放开「哪一类操作」,避免文案与落盘的例外分类不符
2413
+ const KIND_LABEL = { read: ['读取文件', 'file reads'], image: ['读取图片', 'image reads'], edit: ['写入/编辑', 'write/edits'], undo: ['撤销操作', 'undo actions'] }
2414
+ const kindLabel = KIND_LABEL[catKey] || ['此类操作', 'this kind of operation']
2415
+ if (outsideHere) {
2416
+ // 工作区外路径:两道闸各给一条候选。「整个目录」写 directory 与该分类的目录 glob ——
2417
+ // 点一次后该类操作在该目录下都不再询问;「仅此文件」写自身分类例外 + directory 的精确路径例外。
2418
+ // 目录 glob 只对普通路径生成:路径自身含通配符或 .. 段时,拼出的 glob 会匹配到该目录之外
2419
+ // 的路径(dirGlob / norm / globToRegExp 都不折叠 ..),授权面会超过「整个目录」的文案。
2420
+ const globSafe = !hasGlobMeta(entry.value) && !hasParentSeg(entry.value)
2421
+ const glob = globSafe ? dirGlob(entry.value) : ''
2422
+ const hasDirGlob = globSafe && alreadyInProject(glob, 'path', 'directory')
2423
+ const hasKindGlob = globSafe && alreadyInProject(glob, 'path', catKey)
2424
+ if (globSafe && (!hasDirGlob || !hasKindGlob)) {
2425
+ push(t('整个目录:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」两条例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该目录的其它类型操作):', 'Whole directory: ' + kindLabel[1] + ' (an allow writes both the ' + kindLabel[1] + ' rule and the directory-access rule; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations in that directory): ') + glob, glob, 'path', [{ cat: 'directory', kind: 'path', value: glob }, { cat: catKey, kind: 'path', value: glob }])
2426
+ }
2427
+ if (!hasGlobMeta(entry.value) && !alreadyInProject(entry.value, 'path', catKey)) {
2428
+ push(t('仅此文件:' + kindLabel[0] + '(允许时同时写入「' + kindLabel[0] + '」与「目录访问」的精确路径例外;拒绝时只写入「' + kindLabel[0] + '」,不连带封禁该路径的其它类型操作):', 'Only this file: ' + kindLabel[1] + ' (an allow writes exact-path rules for both the ' + kindLabel[1] + ' and directory access; a deny writes only the ' + kindLabel[1] + ' rule and does not block other kinds of operations on that path): ') + entry.value, entry.value, 'path', [{ cat: catKey, kind: 'path', value: entry.value }, { cat: 'directory', kind: 'path', value: entry.value }])
2429
+ }
2430
+ } else if (!hasGlobMeta(entry.value)) {
2431
+ if (!alreadyInProject(entry.value, 'path', entry.cat)) push(entry.value, entry.value, 'path')
2432
+ }
1668
2433
  }
1669
2434
  // 其余分类无「例外」候选:快捷工具(web_search/skill 等)走 quickTools 设置;
1670
2435
  // 子代理/重复操作只有模式默认值 —— 均不生成候选
@@ -1677,7 +2442,6 @@ export default {
1677
2442
  let onAbort = null
1678
2443
  const id = 'p' + Math.random().toString(36).slice(2, 10)
1679
2444
  const argsJson = safeJson(exec.arguments)
1680
- const argsPreview = argsJson && argsJson.length > 160 ? argsJson.slice(0, 160) + '…' : (argsJson || '')
1681
2445
  const taskText = argDescription(exec.arguments) || recentUserText(exec)
1682
2446
  const entry = {
1683
2447
  id,
@@ -1694,8 +2458,14 @@ export default {
1694
2458
  // 审批发起时的项目根:root 是跨会话共享的闭包变量,随后可能被其他会话覆盖,
1695
2459
  // 打相对路径/对比/打开文件必须用发起会话自己的根
1696
2460
  projRoot: root || null,
1697
- // 编辑/写入且有文件路径弹窗「详情」默认展开、按需取对比数据
1698
- hasDiff: !!FILE_WRITE_TOOLS[exec.name] && !!pathArg(exec.arguments),
2461
+ // 编辑/写入,或带文件路径的读取弹窗「详情」默认展开、按需取数据
2462
+ // (写类=diff,读类=窗口化内容;图片是整图 data URL,改由客户端默认收起、点开才拉取)
2463
+ hasDiff: isPreviewableFileTool(exec.name, exec.arguments) && !!pathArg(exec.arguments),
2464
+ imagePreview: isFileImage(exec.name) === true,
2465
+ toolCat: pathToolCat(exec.name, exec.arguments),
2466
+ // str_replace_editor 的内核在审批发起时定下(insert 的 insert_line 语义随内核相反),
2467
+ // 详情预览按发起时的实际内核解释,避免中途判别漂移
2468
+ editorKernel: resolveEditorKernel(exec).kernel,
1699
2469
  resolve,
1700
2470
  cleanup() {
1701
2471
  if (onAbort && exec.signal) { try { exec.signal.removeEventListener('abort', onAbort) } catch (e) {} }
@@ -1720,36 +2490,89 @@ export default {
1720
2490
  })
1721
2491
  }
1722
2492
 
1723
- function addProjectRule(entry, kind, value, decision) {
2493
+ // 例外落盘单点:按「分类 + 类型(path/command/custom)+ 值」写入目标块(缺省全局,
2494
+ // 候选写入由调用方显式指定项目块)。
2495
+ // 候选各自携带目标分类(buildCandidates 的 writes),不再由 entry.cat 统一决定——
2496
+ // 否则「仅允许此文件」这类要写两条例外(自身分类 + 目录闸)的候选会写错分类。
2497
+ function addProjectException(cat, kind, value, decision, opts) {
2498
+ const o = opts || {}
2499
+ const target = o.target === 'project' ? 'project' : 'global'
2500
+ // reason 仅用于 deny,且与 normalizeException 同口径(trim + 截断 200):
2501
+ // 否则「写入当下」与「重新加载后」的条目字段会不一致(allow 的 reason 会被丢弃)。
2502
+ const reason = decision === 'deny' && o.reason ? String(o.reason).trim().slice(0, 200) : undefined
2503
+ // 新条目一律插到数组头部:resolveCategory 只取首个匹配,即「最新决定先生效」;
2504
+ // 同方向重复写入直接跳过并返回既有条目,避免同一决定在列表里堆积。
2505
+ const build = (extra) => Object.assign({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision }, extra, reason ? { reason } : {})
1724
2506
  try {
1725
- const block = ensureProject()
1726
- if (kind === 'path' && entry.cat && EXC_CATS.indexOf(entry.cat) !== -1) {
1727
- const cat = block[entry.cat] || freshCategory(entry.cat, true)
1728
- if (!cat.exceptions) cat.exceptions = []
1729
- const idx = cat.exceptions.findIndex((r) => r.path === value)
1730
- if (idx !== -1) { cat.exceptions[idx].action = decision; block[entry.cat] = cat; return }
1731
- cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision, path: value })
1732
- block[entry.cat] = cat
1733
- return
1734
- }
1735
- if (kind === 'command') {
1736
- const cat = block.command || freshCategory('command', true)
1737
- if (!cat.exceptions) cat.exceptions = []
1738
- const idx = cat.exceptions.findIndex((r) => r.match === value)
1739
- if (idx !== -1) { cat.exceptions[idx].action = decision; block.command = cat; return }
1740
- cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action: decision, match: value })
1741
- block.command = cat
1742
- return
2507
+ const block = target === 'global' ? config.global : ensureProject()
2508
+ if (kind === 'path' && cat && cat !== 'command' && EXC_CATS.indexOf(cat) !== -1) {
2509
+ const c = block[cat] || freshCategory(cat, target === 'project')
2510
+ if (!c.exceptions) c.exceptions = []
2511
+ const idx = c.exceptions.findIndex((r) => r.path === value && r.action === decision)
2512
+ if (idx !== -1) {
2513
+ // 命中既有同向条目:提到数组头部,否则它会被前面的反向旧条目遮蔽(resolveCategory 只取首个匹配),
2514
+ // 用户的决定等于被静默丢弃;带新理由时一并回写(reason 已在入口按 deny + trim + 200 规范化)。
2515
+ const hit = c.exceptions.splice(idx, 1)[0]
2516
+ if (reason) hit.reason = reason
2517
+ c.exceptions.unshift(hit)
2518
+ block[cat] = c
2519
+ return hit
2520
+ }
2521
+ const item = build({ path: value })
2522
+ c.exceptions.unshift(item)
2523
+ block[cat] = c
2524
+ return item
2525
+ }
2526
+ if (kind === 'path') return null
2527
+ if (kind === 'command' && cat === 'command') {
2528
+ const c = block.command || freshCategory('command', target === 'project')
2529
+ if (!c.exceptions) c.exceptions = []
2530
+ const idx = c.exceptions.findIndex((r) => r.match === value && r.action === decision)
2531
+ if (idx !== -1) {
2532
+ const hit = c.exceptions.splice(idx, 1)[0]
2533
+ if (reason) hit.reason = reason
2534
+ c.exceptions.unshift(hit)
2535
+ block.command = c
2536
+ return hit
2537
+ }
2538
+ const item = build({ match: value })
2539
+ c.exceptions.unshift(item)
2540
+ block.command = c
2541
+ return item
1743
2542
  }
2543
+ if (kind === 'command') return null
1744
2544
  if (!block.custom) block.custom = []
1745
2545
  const idx = block.custom.findIndex((r) => r.tool === value)
1746
- if (idx !== -1) { block.custom[idx].action = decision; return }
1747
- block.custom.unshift({ id: 'r' + Math.random().toString(36).slice(2, 8), action: decision, tool: value })
2546
+ if (idx !== -1) { block.custom[idx].action = decision; return block.custom[idx] }
2547
+ const item = { id: 'r' + Math.random().toString(36).slice(2, 8), action: decision, tool: value }
2548
+ block.custom.unshift(item)
2549
+ return item
1748
2550
  } catch (e) {
1749
- console.error('[permgate] addProjectRule error:', e)
2551
+ console.error('[permgate] addProjectException error:', e)
2552
+ return null
1750
2553
  }
1751
2554
  }
1752
2555
 
2556
+ // 例外删除单点(/permgate/remove-exception 路由与 perm_remove_exception 工具共用):
2557
+ // 判定只取数组首个匹配,故同一 path 上可能并存方向相反的多条(最新在前生效)。
2558
+ // 删除严格按 id:只删用户点的那一行。同值同向的其它条目(方向交替写入可能累积)留给用户
2559
+ // 自行逐条清理,否则删一条历史行会连带删掉正在生效的那条,权限会静默变化。
2560
+ function removeExceptionEntries(block, catKey, id) {
2561
+ if (EXC_CATS.indexOf(catKey) === -1) return { removed: false, reason: '该分类不支持例外' }
2562
+ const c = block && block[catKey]
2563
+ if (!c || !Array.isArray(c.exceptions)) return { removed: false, reason: '例外列表不存在' }
2564
+ const target = c.exceptions.find((r) => r.id === id)
2565
+ if (!target) return { removed: false, reason: '未找到 id=' + id }
2566
+ const key = catKey === 'command' ? 'match' : 'path'
2567
+ const value = target[key]
2568
+ const kept = c.exceptions.filter((r) => r.id !== id)
2569
+ const count = c.exceptions.length - kept.length
2570
+ c.exceptions = kept
2571
+ // 同值、方向相反的条目可能仍然留在列表里并继续生效:回传给 UI 提示,避免用户以为已彻底清除
2572
+ const remaining = kept.filter((r) => r[key] === value).length
2573
+ return { removed: true, count, remaining, exception: target }
2574
+ }
2575
+
1753
2576
  function addRememberedRule(entry, action, target) {
1754
2577
  try {
1755
2578
  const block = target === 'project' ? ensureProject() : config.global
@@ -1759,21 +2582,13 @@ export default {
1759
2582
  return
1760
2583
  }
1761
2584
  if (entry.kind === 'path' && entry.cat && EXC_CATS.indexOf(entry.cat) !== -1 && entry.value) {
1762
- const cat = block[entry.cat] || freshCategory(entry.cat, target === 'project')
1763
- if (!cat.exceptions) cat.exceptions = []
1764
- const idx = cat.exceptions.findIndex((r) => r.path === entry.value)
1765
- if (idx !== -1) { cat.exceptions[idx].action = action; block[entry.cat] = cat; return }
1766
- cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, path: entry.value })
1767
- block[entry.cat] = cat
2585
+ // 复用例外写入单点。注意:choice 是用户在弹窗上的显式选择(「拒绝并加入项目黑名单」),
2586
+ // 不能套用候选双写场景的「deny 不写 directory 例外」过滤,否则显式决定会被静默丢弃。
2587
+ addProjectException(entry.cat, 'path', String(entry.value), action, { target })
1768
2588
  return
1769
2589
  }
1770
2590
  if (entry.kind === 'command' && entry.cat === 'command' && entry.value) {
1771
- const cat = block.command || freshCategory('command', target === 'project')
1772
- if (!cat.exceptions) cat.exceptions = []
1773
- const idx = cat.exceptions.findIndex((r) => r.match === entry.value)
1774
- if (idx !== -1) { cat.exceptions[idx].action = action; block.command = cat; return }
1775
- cat.exceptions.unshift({ id: 'e' + Math.random().toString(36).slice(2, 8), action, match: entry.value })
1776
- block.command = cat
2591
+ addProjectException('command', 'command', String(entry.value), action, { target })
1777
2592
  return
1778
2593
  }
1779
2594
  if (!block.custom) block.custom = []
@@ -1789,11 +2604,44 @@ export default {
1789
2604
 
1790
2605
  // 最近一次成功读取/写入的磁盘原文:persist 前与磁盘比对,防止覆盖外部手工编辑
1791
2606
  let lastDiskJson = null
2607
+ // 加载失败哨兵:load 未能建立磁盘基线时置此值,persist 守卫据此拒绝保存,
2608
+ // 防止「配置存在但读取失败」后下一次 persist 静默覆盖磁盘上的用户配置
2609
+ const LOAD_FAILED_MARK = '\u0000__PERMGATE_LOAD_FAILED__'
1792
2610
 
1793
2611
  async function persist(exec) {
1794
2612
  try {
1795
- const t = await ensureTarget(exec)
2613
+ let t = await ensureTarget(exec)
1796
2614
  await ensureConfigDir()
2615
+ // 配置只应写到 home:home 不可用时会退到 <root>/.dsh/.permgate.json,而 DSH 的 writeText
2616
+ // 会自动 mkdir 父目录——一旦落盘就在项目里留下 .dsh(home 恢复后这份配置还会成为孤儿)。
2617
+ // 故此处直接拒绝,等 home 就绪后由 load() 重新落盘。
2618
+ const homeNow = await resolveDshHome()
2619
+ const pathOf = (v) => norm(pathString(v)).toLowerCase()
2620
+ // home 归属判定两侧必须同口径:pathOf 取的是 realpath(processPath),故 home 前缀也要由
2621
+ // 解析后的 home 目标派生——否则 home 含 junction/符号链接时前缀恒不匹配,保存会被永久拒绝
2622
+ const homeTarget = homeNow ? await homeConfigTarget(homeNow) : null
2623
+ const homePrefix = homeTarget ? pathOf(homeTarget).replace(/[\\/][^\\/]*$/, '/') : ''
2624
+ const inHome = !!(homeTarget && homePrefix && pathOf(t).indexOf(homePrefix) === 0)
2625
+ // home 已恢复但 target 仍停在回退路径时就地重算,省掉一次「重新加载配置文件」
2626
+ if (!inHome && homeTarget) {
2627
+ target = homeTarget
2628
+ t = target
2629
+ // 目标切换后旧基线不再属于该目标:基线为空却直接放行,会用内存里的(可能默认)配置
2630
+ // 覆盖 home 上已存在的用户配置——故 home 已有文件时拒绝保存并要求先重新加载
2631
+ if (lastDiskJson === null && await configExists(fs, t)) {
2632
+ saveError = uiLang === 'en' ? 'Config target switched to DSH home; reload the config file before saving.' : '配置路径已切换到 DSH home,请先「重新加载配置文件」再保存'
2633
+ broadcast({ type: 'status' })
2634
+ return false
2635
+ }
2636
+ }
2637
+ if (!homeTarget || !homePrefix || pathOf(t).indexOf(homePrefix) !== 0) {
2638
+ // 区分「home 不可用」与「目标不在 home 下」:前者稍后重试即可,后者需要重新加载配置
2639
+ saveError = homeTarget
2640
+ ? (uiLang === 'en' ? 'Config target is outside the DSH home; reload the config file before saving.' : '配置目标不在 DSH home 下,请先「重新加载配置文件」再保存')
2641
+ : (uiLang === 'en' ? 'DSH home is not ready; save skipped to avoid creating a stray .dsh in the project. Retry shortly.' : 'DSH home 未就绪,已跳过保存(避免在项目里产生 .dsh);稍后重试即可')
2642
+ broadcast({ type: 'status' })
2643
+ return false
2644
+ }
1797
2645
  // 防覆盖守卫:配置在加载后被外部修改(手工编辑、其他实例写入)时拒绝保存,
1798
2646
  // 避免静默覆盖用户规则;点击「重新加载配置文件」后守卫自动放行。
1799
2647
  try {
@@ -1803,7 +2651,16 @@ export default {
1803
2651
  broadcast({ type: 'status' })
1804
2652
  return false
1805
2653
  }
1806
- } catch (e) {}
2654
+ } catch (e) {
2655
+ // 读取失败:先用 stat 区分「目标不存在」与「存在但读不出」——
2656
+ // 目标不存在(首次落盘 / 迁移到新路径)视为尚无磁盘内容,允许写入并重建基线;
2657
+ // 已建立过基线却读不出(被占用、权限、IO 错误)时保守拒绝,避免静默覆盖用户手工编辑的规则
2658
+ if (await configExists(fs, t) && lastDiskJson !== null) {
2659
+ saveError = uiLang === 'en' ? 'Config file unreadable; save cancelled. Check the file (locked / permission / non-text encoding), then click "Reload config file".' : '配置文件无法读取,已取消保存;请检查该文件(被占用/权限/非文本编码)后再点击「重新加载配置文件」'
2660
+ broadcast({ type: 'status' })
2661
+ return false
2662
+ }
2663
+ }
1807
2664
  const writePolicy = { mode: 'danger-full-access', workspaceRoot: root }
1808
2665
  await fs.writeText(t, JSON.stringify(config, null, 2), undefined, undefined, writePolicy)
1809
2666
  lastDiskJson = JSON.stringify(config, null, 2)
@@ -1816,9 +2673,73 @@ export default {
1816
2673
  }
1817
2674
  }
1818
2675
 
2676
+ // 项目残留配置并入(迁移用):只采纳 projects 段——工作区内的 .dsh/.permgate.json 可能随
2677
+ // 仓库分发或被 agent 写入(不可信),其 global 段一律忽略,避免 clone 即得的宽松「全局」
2678
+ // 策略覆盖用户配置;源里若只有旧格式 global(无 projects)则不迁移,从而不会走 migrateOld
2679
+ // (旧模式 permissive 会被映射成全 allow)。源不可解析或无可迁移内容时返回 null。
2680
+ function projectsFromConfig(srcText) {
2681
+ try {
2682
+ const src = JSON.parse(String(srcText == null ? '' : srcText))
2683
+ if (!src || typeof src !== 'object') return null
2684
+ const projects = src.projects && typeof src.projects === 'object' ? src.projects : null
2685
+ if (!projects) return null
2686
+ // 只接纳当前工作区自己的条目:工作区文件可能随仓库分发或被 agent 写入(不可信),
2687
+ // 其他 key 会被 cleanupStaleProjects 逐个 resolve/stat(UNC 会触发网络访问),
2688
+ // 也会长期套用于别的项目,故一律丢弃
2689
+ const rootKey = normPathKey(root)
2690
+ const keep = {}
2691
+ if (rootKey) {
2692
+ for (const key of Object.keys(projects)) {
2693
+ if (normPathKey(key) !== rootKey) continue
2694
+ keep[key] = projects[key]
2695
+ }
2696
+ }
2697
+ if (!Object.keys(keep).length) return null
2698
+ return JSON.stringify({ projects: keep })
2699
+ } catch (e) { return null }
2700
+ }
2701
+
2702
+ // 迁移成功后清理项目残留配置文件(删除失败只影响清理,不影响已完成的落盘)
2703
+ function removeMigratedSource(p) {
2704
+ try {
2705
+ const raw = pathString(p)
2706
+ // 迁移源必须是工作区内那个字面文件:targetKey 为 realpath,符号链接会让删除落到
2707
+ // 工作区外的真实文件,故以「父目录 realpath + 文件名」构造期望路径,再与文件 realpath 比较
2708
+ if (!raw) return false
2709
+ let want = ''
2710
+ try { want = pathJoin(fsRealpathSync(pathResolve(root, '.dsh')), '.permgate.json') } catch (e2) { return false }
2711
+ let st = null
2712
+ try { st = fsLstatSync(raw) } catch (e2) { return false }
2713
+ if (!st || !st.isFile()) return false
2714
+ const real = fsRealpathSync(raw)
2715
+ if (normPathKey(real) !== normPathKey(want)) return false
2716
+ fsUnlinkSync(real)
2717
+ return true
2718
+ } catch (e) { return false }
2719
+ }
2720
+
2721
+ // home 配置探测统一(missing 分支重试与非 missing 分支校验共用):
2722
+ // 返回 {target, text}(可读)、{target, missing:true}(不存在)、{target, readFailed:true}(存在但读失败)、null(home 不可用)
2723
+ async function probeHomeConfig() {
2724
+ const homeNow = await resolveDshHome()
2725
+ if (!homeNow) return null
2726
+ const target = await homeConfigTarget(homeNow)
2727
+ if (!target) return null
2728
+ const exists = await configExists(fs, target)
2729
+ if (!exists) return { target, missing: true }
2730
+ try {
2731
+ const text = await fs.readText(target)
2732
+ if (text === null) return { target, readFailed: true }
2733
+ return { target, text }
2734
+ } catch (e) { return { target, readFailed: true } }
2735
+ }
2736
+
1819
2737
  async function load(exec) {
1820
2738
  try {
1821
2739
  const t = await ensureTarget(exec)
2740
+ let migratedFromProject = false
2741
+ let migratedFromPath = null
2742
+ let projReadFailed = false
1822
2743
  let text = null
1823
2744
  let missing = false
1824
2745
  try {
@@ -1832,13 +2753,80 @@ export default {
1832
2753
  // 文件不存在 → 首次运行,落盘默认配置;
1833
2754
  // 存在但读取失败 → 保留内存配置并提示,绝不静默覆盖磁盘(避免误删规则)
1834
2755
  if (missing) {
1835
- loadError = null
1836
- config = freshConfig()
1837
- await persist(exec)
2756
+ // 初始化早期 subprocess 可能未就绪导致 home 解析失败、target 落到项目目录;
2757
+ // 落盘默认配置前重试一次 home 定位,优先复用 home 配置(避免在项目内新建配置文件)
2758
+ const hp = await probeHomeConfig()
2759
+ if (hp) {
2760
+ if (hp.text !== undefined) {
2761
+ target = hp.target
2762
+ text = hp.text
2763
+ } else if (hp.readFailed) {
2764
+ // home 配置存在但读取失败:保留内存配置并提示,绝不静默覆盖磁盘
2765
+ lastDiskJson = LOAD_FAILED_MARK
2766
+ loadError = uiLang === 'en' ? 'Cannot read config file: ' + hp.target : '无法读取配置文件: ' + hp.target
2767
+ return
2768
+ } else {
2769
+ // home 配置不存在:检查项目目录残留配置(1.3.x 竞态期可能 persist 到项目
2770
+ // .dsh/.permgate.json),存在且可读则迁移为初始配置(解析后落盘 homeT),避免用户规则静默丢失
2771
+ const projCfg = root ? await fs.resolve(root + '/.dsh/.permgate.json') : null
2772
+ if (projCfg) {
2773
+ const projExists = await configExists(fs, projCfg)
2774
+ if (projExists) {
2775
+ try {
2776
+ const projText = await fs.readText(projCfg)
2777
+ // 并入项目残留配置:只采纳 projects 段(工作区内文件不可信,其 global 一律忽略),
2778
+ // 迁移成功落盘后再删除源文件
2779
+ const mergedText = projectsFromConfig(projText)
2780
+ if (mergedText !== null) { target = hp.target; text = mergedText; migratedFromProject = true; migratedFromPath = projCfg }
2781
+ } catch (e) {
2782
+ // 残留存在但读不出(权限/占用/非 UTF-8):与 home 的 readFailed 同口径——置哨兵并提示,
2783
+ // 且不落默认配置,避免项目残留里的用户规则被静默放弃(此前会永久跳过迁移)
2784
+ projReadFailed = true
2785
+ }
2786
+ }
2787
+ }
2788
+ }
2789
+ }
2790
+ if (text === null) {
2791
+ // 项目残留配置存在却读不出:不落默认配置(否则 home 一旦写入就再也不迁移),
2792
+ // 置哨兵并提示,修复该文件后重新加载即可继续迁移
2793
+ if (projReadFailed) {
2794
+ lastDiskJson = LOAD_FAILED_MARK
2795
+ loadError = uiLang === 'en' ? 'Cannot read the project residual config; migration skipped. Fix that file and reload.' : '项目残留配置无法读取,已跳过迁移;修复该文件后重新加载即可'
2796
+ return
2797
+ }
2798
+ loadError = null
2799
+ config = freshConfig()
2800
+ // 正常初始化路径:清除加载失败哨兵,允许创建默认配置
2801
+ lastDiskJson = null
2802
+ // home 可能在本次探测中已恢复:显式把 target 切回 home,避免默认配置
2803
+ // 落到项目回退路径(DSH 的 writeText 会自动 mkdir,从而在项目里留下 .dsh)
2804
+ const homeNow = await resolveDshHome()
2805
+ if (homeNow) target = await homeConfigTarget(homeNow)
2806
+ await persist(exec)
2807
+ return
2808
+ }
1838
2809
  } else {
2810
+ // 配置存在但读取失败:置加载失败哨兵,阻止后续 persist 静默覆盖
2811
+ lastDiskJson = LOAD_FAILED_MARK
1839
2812
  loadError = uiLang === 'en' ? 'Cannot read config file: ' + t : '无法读取配置文件: ' + t
2813
+ return
2814
+ }
2815
+ }
2816
+ // home 配置优先:竞态期 target 可能落在项目目录且项目残留配置存在(missing=false 时
2817
+ // 上面不会重试 home)。此时若 home 可解析且 home 配置存在,切回 home,避免永久使用项目旧配置。
2818
+ // 只有当 target 不是 home 配置(竞态期落在项目回退路径)时才探测:否则会对同一份
2819
+ // 文件重复 resolve+stat+readText(load 顶部已读过一次)
2820
+ if (!missing && text !== null) {
2821
+ const homeNow = await resolveDshHome()
2822
+ const homeTarget = homeNow ? await homeConfigTarget(homeNow) : null
2823
+ if (homeTarget && normPathKey(pathString(homeTarget)) !== normPathKey(pathString(target))) {
2824
+ const hp = await probeHomeConfig()
2825
+ if (hp && hp.text !== undefined && hp.target !== target) {
2826
+ target = hp.target
2827
+ text = hp.text
2828
+ }
1840
2829
  }
1841
- return
1842
2830
  }
1843
2831
  const parsed = JSON.parse(text)
1844
2832
  if (!parsed || typeof parsed !== 'object') throw new Error('根节点必须是对象')
@@ -1846,10 +2834,16 @@ export default {
1846
2834
  config = isOld ? migrateOld(parsed) : buildConfig(parsed)
1847
2835
  lastDiskJson = String(text).trim()
1848
2836
  loadError = null
1849
- if (isOld) await persist(exec)
2837
+ saveError = null
2838
+ if (isOld || migratedFromProject) {
2839
+ const saved = await persist(exec)
2840
+ // 迁移成功落盘后才删除项目残留文件,避免写盘失败导致配置丢失
2841
+ if (saved && migratedFromPath) removeMigratedSource(migratedFromPath)
2842
+ }
1850
2843
  broadcast({ type: 'status' })
1851
2844
  } catch (e) {
1852
2845
  loadError = '配置解析失败: ' + ((e && e.message) || String(e))
2846
+ lastDiskJson = LOAD_FAILED_MARK
1853
2847
  }
1854
2848
  }
1855
2849
 
@@ -1858,7 +2852,7 @@ export default {
1858
2852
  const proj = projectBlock()
1859
2853
  const effective = {}
1860
2854
  for (const c of CATS) {
1861
- effective[c] = (proj && proj[c] && proj[c].mode && proj[c].mode !== 'inherit') ? proj[c].mode : (config.global[c] ? config.global[c].mode : 'allow')
2855
+ effective[c] = firstEffective(proj && proj[c] && proj[c].mode, config.global[c] && config.global[c].mode, 'allow')
1862
2856
  }
1863
2857
  const stats = { deny: 0, ask: 0 }
1864
2858
  for (const d of decisions) {
@@ -1889,6 +2883,10 @@ export default {
1889
2883
  global: config.global.quickTools || {},
1890
2884
  project: (proj && proj.quickTools) || {},
1891
2885
  },
2886
+ // 预设清单下发(单一来源为 QUICK_DEFAULTS):浏览器半边不再硬编码工具名清单
2887
+ quickPreset: QUICK_PRESET,
2888
+ // 预设默认动作一并下发:面板未配置行按「项目键 → 全局键 → 预设默认 → 兜底」显示,与 quickAction 同链
2889
+ quickDefaults: QUICK_DEFAULTS,
1892
2890
  custom: {
1893
2891
  global: config.global.custom || [],
1894
2892
  project: (proj && proj.custom) || [],
@@ -1900,6 +2898,8 @@ export default {
1900
2898
  stats,
1901
2899
  recentDecisions: decisions.slice(-10).map((d) => Object.assign({}, d, { reason: typeof d.reason === 'string' ? d.reason : L(d.reason, l) })),
1902
2900
  cats: CATS,
2901
+ editorKernel: { setting: editorKernelSetting(), ...resolveEditorKernel(exec) },
2902
+ fallback: { global: config.global.fallbackMode || 'ask', project: (proj && proj.fallbackMode) || 'inherit', effective: fallbackMode() },
1903
2903
  excCats: EXC_CATS,
1904
2904
  modes: MODES,
1905
2905
  allModes: ALL_MODES,
@@ -1983,7 +2983,7 @@ export default {
1983
2983
  const argsPreview = e.argsJson && e.argsJson.length > 160 ? e.argsJson.slice(0, 160) + '…' : (e.argsJson || '')
1984
2984
  const reason = typeof e.reason === 'string' ? e.reason : L(e.reason, lang)
1985
2985
  const intent = typeof e.intent === 'string' ? e.intent : L(e.intent, lang)
1986
- 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 })
2986
+ 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, imagePreview: e.imagePreview === true })
1987
2987
  }
1988
2988
  return json(res, out)
1989
2989
  }
@@ -2012,13 +3012,37 @@ export default {
2012
3012
  if (!entry) return json(res, { error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
2013
3013
  let allow = false
2014
3014
  let ruleCount = 0
3015
+ // 例外落盘单点(候选路径与旧形态规则共用):内含「拒绝时不写 directory 例外」这道安全过滤
3016
+ // 与计数,避免同一决定因入口不同而落盘范围不同;候选写入一律显式落到项目块。
3017
+ const writeException = (cat, kind, value, decision) => {
3018
+ if (decision === 'deny' && cat === 'directory') return
3019
+ addProjectException(cat, kind, value, decision, { target: 'project' })
3020
+ ruleCount++
3021
+ }
3022
+ const writeCandidate = (cand, decision) => {
3023
+ for (const w of cand.writes) writeException(w.cat, w.kind || 'path', String(w.value), decision)
3024
+ }
2015
3025
  if (typeof a.action === 'string' && (a.action === 'allow' || a.action === 'deny')) {
2016
3026
  allow = a.action === 'allow'
2017
3027
  if (Array.isArray(a.rules)) {
2018
3028
  for (const r of a.rules) {
2019
- if (r && r.value && (r.decision === 'allow' || r.decision === 'deny')) {
2020
- addProjectRule(entry, r.kind || null, String(r.value), r.decision)
2021
- ruleCount++
3029
+ if (!r || (r.decision !== 'allow' && r.decision !== 'deny')) continue
3030
+ // 整体为拒绝时不接受 allow 方向的规则:前端可能残留「允许此项」的勾选,
3031
+ // 若不拦就会变成「本次拒绝 + 持久化一条 allow 例外」,与用户显式拒绝的意图相反。
3032
+ if (!allow && r.decision === 'allow') continue
3033
+ const cand = r.id ? (entry.candidates || []).find((c) => c.id === r.id) : null
3034
+ if (cand && Array.isArray(cand.writes) && cand.writes.length) {
3035
+ writeCandidate(cand, r.decision)
3036
+ continue
3037
+ }
3038
+ if (r.value) {
3039
+ // 旧形态(无候选 id):按 value 反查候选,复用它的 writes,避免只落一条例外而导致同一文件反复弹窗
3040
+ const byValue = (entry.candidates || []).find((c) => c.value === String(r.value))
3041
+ if (byValue && Array.isArray(byValue.writes) && byValue.writes.length) {
3042
+ writeCandidate(byValue, r.decision)
3043
+ continue
3044
+ }
3045
+ writeException(entry.cat, r.kind || null, String(r.value), r.decision)
2022
3046
  }
2023
3047
  }
2024
3048
  }
@@ -2042,12 +3066,26 @@ export default {
2042
3066
  }
2043
3067
  if (pathname === '/permgate/set-sandbox' && method === 'POST') {
2044
3068
  await init(exec)
2045
- const target = a.target === 'project' ? 'project' : 'global'
3069
+ const target = normTarget(a)
2046
3070
  if (!setSandboxConfig(target, a.mode)) return json(res, { error: '非法沙箱参数: target=' + target + ' mode=' + a.mode })
2047
3071
  await persist(exec)
2048
3072
  syncSandbox(exec)
2049
3073
  return json(res, statusView(exec))
2050
3074
  }
3075
+ if (pathname === '/permgate/set-fallback' && method === 'POST') {
3076
+ await init(exec)
3077
+ const target = normTarget(a)
3078
+ if (!setFallbackMode(target, a.mode)) return json(res, { error: '非法兜底参数: target=' + target + ' mode=' + a.mode })
3079
+ await persist(exec)
3080
+ return json(res, statusView(exec))
3081
+ }
3082
+ if (pathname === '/permgate/set-editor-kernel' && method === 'POST') {
3083
+ await init(exec)
3084
+ const target = normTarget(a)
3085
+ if (!setEditorKernel(target, a.mode)) return json(res, { error: '非法内核参数: target=' + target + ' mode=' + a.mode })
3086
+ await persist(exec)
3087
+ return json(res, statusView(exec))
3088
+ }
2051
3089
  if (pathname === '/permgate/set-categories' && method === 'POST') {
2052
3090
  await init(exec)
2053
3091
  for (const t of ['global', 'project']) {
@@ -2084,23 +3122,20 @@ export default {
2084
3122
  if (!a.match || !String(a.match)) return json(res, { error: 'match 不能为空' })
2085
3123
  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)
2086
3124
  if (!e) return json(res, { error: '非法的例外参数' })
2087
- const block = a.target === 'project' ? ensureProject() : config.global
2088
- if (!block[a.category]) block[a.category] = freshCategory(a.category, a.target === 'project')
2089
- if (!block[a.category].exceptions) block[a.category].exceptions = []
2090
- block[a.category].exceptions.push(e)
3125
+ // 例外写入统一走 addProjectException:与候选写入共用「同方向不重复、新决定插头部」的语义,
3126
+ // 否则面板新加的例外会排在历史条目之后,被 resolveCategory 的首个匹配静默屏蔽。
3127
+ const written = addProjectException(a.category, a.category === 'command' ? 'command' : 'path', a.match, a.action, { target: a.target, reason: a.reason })
3128
+ if (!written) return json(res, { error: '例外未写入:分类/参数不支持' })
2091
3129
  await persist(exec)
2092
- return json(res, { added: e, status: statusView(exec) })
3130
+ return json(res, { added: written, status: statusView(exec) })
2093
3131
  }
2094
3132
  if (pathname === '/permgate/remove-exception' && method === 'POST') {
2095
3133
  await init(exec)
2096
3134
  const block = a.target === 'project' ? ensureProject() : config.global
2097
- const cat = block[a.category]
2098
- if (!cat || !Array.isArray(cat.exceptions)) return json(res, { removed: false, reason: '例外列表不存在' })
2099
- const idx = cat.exceptions.findIndex((r) => r.id === a.id)
2100
- if (idx === -1) return json(res, { removed: false, reason: '未找到 id=' + a.id })
2101
- const removed = cat.exceptions.splice(idx, 1)[0]
3135
+ const del = removeExceptionEntries(block, a.category, a.id)
3136
+ if (!del.removed) return json(res, { removed: false, reason: del.reason })
2102
3137
  await persist(exec)
2103
- return json(res, { removed: true, exception: removed, status: statusView(exec) })
3138
+ return json(res, { removed: true, exception: del.exception, removedCount: del.count, remaining: del.remaining, status: statusView(exec) })
2104
3139
  }
2105
3140
  if (pathname === '/permgate/add-rule' && method === 'POST') {
2106
3141
  await init(exec)
@@ -2124,6 +3159,8 @@ export default {
2124
3159
  return json(res, { removed: true, rule: removed, status: statusView(exec) })
2125
3160
  }
2126
3161
  if (pathname === '/permgate/reload' && method === 'POST') {
3162
+ // 重置 target 缓存:强制重新解析配置路径,保证竞态残留的项目路径配置可切回 home
3163
+ target = null
2127
3164
  await load(exec)
2128
3165
  return json(res, statusView(exec))
2129
3166
  }
@@ -2156,8 +3193,8 @@ export default {
2156
3193
  if (pathname === '/permgate/open-file' && method === 'POST') {
2157
3194
  const entry = pendingApprovals.get(a.id)
2158
3195
  if (!entry) return json(res, { ok: false, error: lang === 'en' ? 'Approval request not found or expired' : '审批请求不存在或已过期' })
2159
- if (!FILE_READ_TOOLS[entry.tool] && !FILE_WRITE_TOOLS[entry.tool]) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported tool for opening file' : '该审批不支持打开文件' })
2160
3196
  const args = parseEntryArgs(entry)
3197
+ if (!isPreviewableFileTool(entry.tool, args)) return json(res, { ok: false, error: lang === 'en' ? 'Unsupported tool for opening file' : '该审批不支持打开文件' })
2161
3198
  const fp = pathArg(args)
2162
3199
  if (!fp) return json(res, { ok: false, error: lang === 'en' ? 'Missing file path' : '缺少文件路径' })
2163
3200
  // 仅允许文本/文档类扩展名(点开头文件如 .gitignore 视为无扩展名,Windows 不会执行)
@@ -2242,7 +3279,7 @@ export default {
2242
3279
 
2243
3280
  registerTool({
2244
3281
  name: 'perm_status',
2245
- description: '查看权限网关(permgate)当前生效的分类默认(目录/命令/读取/编辑/子代理/重复操作)、例外、快捷工具、自定义规则、最近决策与配置路径。',
3282
+ description: '查看权限网关(permgate)当前生效的分类默认(目录/命令/读取/读取图片/编辑/撤销操作/子代理/重复操作)、例外、快捷工具、自定义规则、最近决策与配置路径。',
2246
3283
  parameters: {},
2247
3284
  output: { schema: { type: 'json' }, render: renderer() },
2248
3285
  async execute(_args, exec) { await init(exec); return statusView(exec) },
@@ -2250,10 +3287,10 @@ export default {
2250
3287
 
2251
3288
  registerTool({
2252
3289
  name: 'perm_set_category',
2253
- description: '设置一个权限分类的默认动作。分类: directory=目录访问(工作区外), command=执行命令, read=读取文件, edit=编辑文件, subagent=启动子代理, doomloop=重复操作。动作: ask=询问, allow=允许, deny=拒绝; 项目(target=project)还支持 inherit=继承全局。',
3290
+ description: '设置一个权限分类的默认动作。分类: directory=目录访问(工作区外), command=执行命令, read=读取文件, image=读取图片, edit=编辑文件, undo=撤销操作(恢复上次编辑前的内容), subagent=启动子代理, doomloop=重复操作。动作: ask=询问, allow=允许, deny=拒绝; 项目(target=project)还支持 inherit=继承全局。',
2254
3291
  parameters: {
2255
3292
  target: { type: 'string', required: true, enum: ['global', 'project'] },
2256
- category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit', 'subagent', 'doomloop'] },
3293
+ category: { type: 'string', required: true, enum: CATEGORY_ENUM },
2257
3294
  mode: { type: 'string', required: true, enum: ['ask', 'allow', 'deny', 'inherit'], description: '目标动作;inherit 仅适用于项目' },
2258
3295
  },
2259
3296
  output: { schema: { type: 'json' }, render: renderer() },
@@ -2266,12 +3303,44 @@ export default {
2266
3303
  },
2267
3304
  })
2268
3305
 
3306
+ registerTool({
3307
+ name: 'perm_set_fallback',
3308
+ description: '设置「未匹配任何规则」时的兜底动作(默认 ask=询问):ask=每个未匹配的调用都弹审批;allow=直接放行;deny=直接拒绝。directory/command/read/image/edit/undo/subagent/doomloop 之外的所有工具调用都归兜底策略。项目(target=project)还支持 inherit=继承全局。',
3309
+ parameters: {
3310
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
3311
+ mode: { type: 'string', required: true, enum: ALL_MODES, description: '兜底动作;inherit 仅适用于项目' },
3312
+ },
3313
+ output: { schema: { type: 'json' }, render: renderer() },
3314
+ async execute(args, exec) {
3315
+ await init(exec)
3316
+ if (!setFallbackMode(args.target, args.mode)) return { error: '非法的 target/mode 组合' }
3317
+ await persist(exec)
3318
+ return statusView(exec)
3319
+ },
3320
+ })
3321
+
3322
+ registerTool({
3323
+ name: 'perm_set_editor_kernel',
3324
+ description: '设置 str_replace_editor 的内核(默认 auto=自动判别):auto=按当前实际注册的工具描述判别;builtin=DSH 内置(官方语义,insert_line 0 基、插入到该行之后);shadow=dsh-better-edit 的覆盖实现(insert_line 1 基、插入到该行之前)。两者 insert 的 insert_line 语义相反,判别错误会让审批弹窗展示错误位置的改动。项目(target=project)还支持 inherit=继承全局。',
3325
+ parameters: {
3326
+ target: { type: 'string', required: true, enum: ['global', 'project'] },
3327
+ mode: { type: 'string', required: true, enum: EDITOR_KERNEL_VALUES, description: '内核判别方式;inherit 仅适用于项目' },
3328
+ },
3329
+ output: { schema: { type: 'json' }, render: renderer() },
3330
+ async execute(args, exec) {
3331
+ await init(exec)
3332
+ if (!setEditorKernel(args.target, args.mode)) return { error: '非法的 target/mode 组合' }
3333
+ await persist(exec)
3334
+ return statusView(exec)
3335
+ },
3336
+ })
3337
+
2269
3338
  registerTool({
2270
3339
  name: 'perm_add_exception',
2271
- description: '给分类添加一条例外。directory/read/edit 分类用 path(路径 glob,支持 * 与 ** 通配,如 G:/MCP/**、**/*.env);command 分类用 match(命令名或子串,支持 * 通配任意剩余,如 Get-Item * / git status)。例外优先于分类默认动作,仅 allow/deny。',
3340
+ description: '给分类添加一条例外。directory/read/image/edit/undo 分类用 path(路径 glob,支持 * 与 ** 通配,如 G:/MCP/**、**/*.env);command 分类用 match(命令名或子串,支持 * 通配任意剩余,如 Get-Item * / git status)。例外优先于分类默认动作,仅 allow/deny。',
2272
3341
  parameters: {
2273
3342
  target: { type: 'string', required: true, enum: ['global', 'project'] },
2274
- category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit'] },
3343
+ category: { type: 'string', required: true, enum: EXC_CATEGORY_ENUM },
2275
3344
  match: { type: 'string', required: true, description: '路径 glob 或命令名/子串(* 匹配任意剩余)' },
2276
3345
  action: { type: 'string', required: true, enum: ['allow', 'deny'], description: '命中例外后的动作' },
2277
3346
  reason: { type: 'string', description: '自定义拒绝原因(仅 deny 例外生效;allow 例外忽略)' },
@@ -2283,12 +3352,11 @@ export default {
2283
3352
  if (!args.match || !String(args.match)) return { error: 'match 不能为空' }
2284
3353
  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)
2285
3354
  if (!e) return { error: '非法的例外参数' }
2286
- const block = args.target === 'project' ? ensureProject() : config.global
2287
- if (!block[args.category]) block[args.category] = freshCategory(args.category, args.target === 'project')
2288
- if (!block[args.category].exceptions) block[args.category].exceptions = []
2289
- block[args.category].exceptions.push(e)
3355
+ // 与面板路由共用同一写入点:同方向不重复、新决定插到数组头部,避免被历史条目遮蔽。
3356
+ const written = addProjectException(args.category, args.category === 'command' ? 'command' : 'path', args.match, args.action, { target: args.target, reason: args.reason })
3357
+ if (!written) return { error: '例外未写入:分类/参数不支持' }
2290
3358
  await persist(exec)
2291
- return { added: e, status: statusView(exec) }
3359
+ return { added: written, status: statusView(exec) }
2292
3360
  },
2293
3361
  })
2294
3362
 
@@ -2297,20 +3365,17 @@ export default {
2297
3365
  description: '按 id 删除一条分类例外(id 见 perm_status 返回的 exceptions 或 perm_add_exception 返回)。',
2298
3366
  parameters: {
2299
3367
  target: { type: 'string', required: true, enum: ['global', 'project'] },
2300
- category: { type: 'string', required: true, enum: ['directory', 'command', 'read', 'edit'] },
3368
+ category: { type: 'string', required: true, enum: EXC_CATEGORY_ENUM },
2301
3369
  id: { type: 'string', required: true },
2302
3370
  },
2303
3371
  output: { schema: { type: 'json' }, render: renderer() },
2304
3372
  async execute(args, exec) {
2305
3373
  await init(exec)
2306
3374
  const block = args.target === 'project' ? ensureProject() : config.global
2307
- const cat = block[args.category]
2308
- if (!cat || !Array.isArray(cat.exceptions)) return { removed: false, reason: '例外列表不存在', status: statusView(exec) }
2309
- const idx = cat.exceptions.findIndex((r) => r.id === args.id)
2310
- if (idx === -1) return { removed: false, reason: '未找到 id=' + args.id, status: statusView(exec) }
2311
- const removed = cat.exceptions.splice(idx, 1)[0]
3375
+ const del = removeExceptionEntries(block, args.category, args.id)
3376
+ if (!del.removed) return { removed: false, reason: del.reason, status: statusView(exec) }
2312
3377
  await persist(exec)
2313
- return { removed: true, exception: removed, status: statusView(exec) }
3378
+ return { removed: true, exception: del.exception, removedCount: del.count, remaining: del.remaining, status: statusView(exec) }
2314
3379
  },
2315
3380
  })
2316
3381