@mrweicodes/dsh-permgate 1.3.11 → 1.5.1

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