pi-web-ui 0.94.1 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,596 @@
1
+ /**
2
+ * server/approval-rules.ts
3
+ *
4
+ * 审批规则库与多维匹配引擎(Approval Rules Store & Match Engine)。
5
+ *
6
+ * 功能:
7
+ * 1. 规则数据模型(ApprovalRule / UiApprovalRule):
8
+ * - 工具名单(tools:["bash"]、["write", "edit"]、["*"])
9
+ * - 匹配字段(field:"command" | "path" | "params")
10
+ * - 匹配方式(match:"regex" | "glob" | "contains" | "prefix" | "outside_workspace")
11
+ * - 命中动作(action:"ask" | "deny" | "allow")
12
+ * - "deny":直接阻断执行并向模型报错(不弹窗、不产生 pending)
13
+ * - "ask":触发人工协同审批(弹窗让用户决定,带档位可「允许同类」)
14
+ * - "allow":免审直接放行(白名单,跳过后续规则与内置高危检测)
15
+ * - 启停开关(enabled)+ 内置标记(builtin)
16
+ * - 双语名称与拦截原因(label/labelEn, reason/reasonEn)
17
+ *
18
+ * 2. 匹配与判定纯函数(matchApprovalRule / evaluateApprovalRules):
19
+ * - 优先级规则:
20
+ * 先按规则列表自顶向下顺序遍历,遇到第一个匹配成功的启用规则即返回其判决;
21
+ * 若命中 "allow",直接免审放行;
22
+ * 若命中 "deny",直接拒绝执行;
23
+ * 若命中 "ask",挂起审批弹窗;
24
+ * 若所有规则均未命中,回落至内置默认安全策略(兼容旧硬编码逻辑)。
25
+ *
26
+ * 3. 存储与多客户端同步(ApprovalRulesStore):
27
+ * - 存储路径:<dataDir>/approval-rules.json(所有客户端共享同一份)
28
+ * - 播种 sidecar:<dataDir>/approval-rules.seeded.json(记录已播种过的内置规则 id,
29
+ * 老用户删掉的内置规则不会在重启后复活;发版新增的内置规则可安全合并进老用户文件)。
30
+ * - 内存缓存 + 文件 mtime 感知,支持直接编辑 JSON 文件热生效。
31
+ */
32
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
33
+ import { dirname, resolve, sep } from "node:path";
34
+ /**
35
+ * 跨平台判定 target 路径是否严格位于 root 目录内部或就是 root 本身。
36
+ * 针对 Windows 盘符大小写不敏感及正反斜杠统一进行规范化,防止 .. 路径穿越逃逸。
37
+ */
38
+ export function isPathInsideRoot(target, root) {
39
+ const normTarget = resolve(target);
40
+ const normRoot = resolve(root);
41
+ if (process.platform === "win32") {
42
+ const lowerTarget = normTarget.toLowerCase();
43
+ const lowerRoot = normRoot.toLowerCase();
44
+ return (lowerTarget === lowerRoot || lowerTarget.startsWith(lowerRoot + sep) || lowerTarget.startsWith(lowerRoot + "/"));
45
+ }
46
+ return normTarget === normRoot || normTarget.startsWith(normRoot + sep);
47
+ }
48
+ /**
49
+ * 极简 glob 转 RegExp(只支持 * 单段、? 单字符、** 跨段)。
50
+ * 纯函数,支持正反斜杠统一归一为 /。
51
+ */
52
+ export function globToRegex(pattern) {
53
+ const src = String(pattern ?? "")
54
+ .trim()
55
+ .replace(/\\/g, "/");
56
+ let re = "";
57
+ for (let i = 0; i < src.length; i++) {
58
+ const c = src[i];
59
+ if (c === "*") {
60
+ if (src[i + 1] === "*") {
61
+ if (src[i + 2] === "/") {
62
+ re += "(?:.*/)?";
63
+ i += 2;
64
+ }
65
+ else {
66
+ re += ".*";
67
+ i += 1;
68
+ }
69
+ }
70
+ else {
71
+ re += "[^/]*";
72
+ }
73
+ }
74
+ else if (c === "?") {
75
+ re += "[^/]";
76
+ }
77
+ else {
78
+ re += c.replace(/[.+^${}()|[\]\\]/, (m) => `\\${m}`);
79
+ }
80
+ }
81
+ return new RegExp(`^${re}$`, "i");
82
+ }
83
+ /**
84
+ * 从工具参数里取「这次调用动的是哪个文件」。SDK 内置 read/write/edit 用 `path`,read 还有
85
+ * `file_path` 别名,部分扩展(如 pi-better-edit 的 edit)用 `file` —— 三种都认,否则叠在
86
+ * 扩展实现之上的权限沙箱与审批规则会静默放行(取不到 → 空串 → 被判成工作区内)。
87
+ */
88
+ export function extractTargetPath(params) {
89
+ if (!params || typeof params !== "object")
90
+ return "";
91
+ const obj = params;
92
+ for (const key of ["path", "file_path", "file"]) {
93
+ const value = obj[key];
94
+ if (typeof value === "string" && value.trim())
95
+ return value;
96
+ }
97
+ return "";
98
+ }
99
+ /**
100
+ * 从工具参数对象中提取待检查文本。
101
+ */
102
+ export function extractRuleFieldValue(field, toolName, params) {
103
+ if (!params || typeof params !== "object") {
104
+ return typeof params === "string" ? params : "";
105
+ }
106
+ const obj = params;
107
+ if (field === "command") {
108
+ return String(obj.command ?? "");
109
+ }
110
+ if (field === "path") {
111
+ return extractTargetPath(params);
112
+ }
113
+ if (field === "params") {
114
+ try {
115
+ return JSON.stringify(params);
116
+ }
117
+ catch {
118
+ return String(params);
119
+ }
120
+ }
121
+ return "";
122
+ }
123
+ /**
124
+ * 单条规则针对具体工具调用的匹配判定(纯函数)。
125
+ */
126
+ export function matchApprovalRule(rule, toolName, params, cwd, workspaceRoots = []) {
127
+ if (!rule.enabled)
128
+ return false;
129
+ // 1. 工具名匹配
130
+ const targetTools = rule.tools.map((t) => t.trim().toLowerCase());
131
+ const toolMatches = targetTools.includes("*") || targetTools.includes(toolName.trim().toLowerCase());
132
+ if (!toolMatches)
133
+ return false;
134
+ // 2. 特殊匹配方式:outside_workspace(工作区外路径)
135
+ if (rule.match === "outside_workspace") {
136
+ const targetPath = extractRuleFieldValue("path", toolName, params);
137
+ if (!targetPath)
138
+ return false;
139
+ // 无论相对或绝对路径,统一经 resolve(cwd, targetPath) 规范化并消除 ".."
140
+ const abs = resolve(cwd, targetPath);
141
+ const allRoots = [resolve(cwd), ...workspaceRoots.map((r) => resolve(r))];
142
+ const inside = allRoots.some((r) => isPathInsideRoot(abs, r));
143
+ return !inside;
144
+ }
145
+ // 3. 提取字段文本
146
+ const fieldValue = extractRuleFieldValue(rule.field, toolName, params);
147
+ // 4. 按 match kind 进行文本判定
148
+ switch (rule.match) {
149
+ case "regex": {
150
+ try {
151
+ const re = new RegExp(rule.value, "i");
152
+ return re.test(fieldValue);
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ case "glob": {
159
+ try {
160
+ const re = globToRegex(rule.value);
161
+ return re.test(fieldValue.replace(/\\/g, "/"));
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ case "contains": {
168
+ return fieldValue.toLowerCase().includes(rule.value.toLowerCase());
169
+ }
170
+ case "prefix": {
171
+ return fieldValue.toLowerCase().startsWith(rule.value.toLowerCase());
172
+ }
173
+ default:
174
+ return false;
175
+ }
176
+ }
177
+ /**
178
+ * 完整评估规则列表对工具调用的决策(纯函数,按列表顺序首个匹配胜出)。
179
+ */
180
+ export function evaluateApprovalRules(rules, toolName, params, cwd, workspaceRoots = []) {
181
+ for (const rule of rules) {
182
+ if (matchApprovalRule(rule, toolName, params, cwd, workspaceRoots)) {
183
+ const catId = rule.categoryId || rule.id;
184
+ const category = {
185
+ id: catId,
186
+ label: rule.label,
187
+ labelEn: rule.labelEn || rule.label,
188
+ };
189
+ return {
190
+ action: rule.action,
191
+ matchedRule: rule,
192
+ reason: rule.reason || rule.label,
193
+ reasonEn: rule.reasonEn || rule.labelEn || rule.label,
194
+ category,
195
+ };
196
+ }
197
+ }
198
+ return { action: "none" };
199
+ }
200
+ /**
201
+ * 内置默认审批规则(与 server/tool-approval.ts 既有内置检测表保持 100% 同口径与同一 id)。
202
+ */
203
+ export const DEFAULT_APPROVAL_RULES = [
204
+ {
205
+ id: "builtin.bash.rm-rf",
206
+ enabled: true,
207
+ tools: ["bash"],
208
+ field: "command",
209
+ match: "regex",
210
+ value: "\\brm\\s+((-[a-zA-Z0-9]*[rf][a-zA-Z0-9]*|--recursive|--force)\\s+)+(((\\/)|(~)|(\\.\\.)|(\\*)|(\\.\\/))|[a-zA-Z]:[\\\\/])",
211
+ action: "ask",
212
+ label: "递归/强制删除 (rm -rf)",
213
+ labelEn: "Recursive/force delete (rm -rf)",
214
+ reason: "检测到高风险的递归/强制删除大范围路径命令 (rm -rf)",
215
+ reasonEn: "Detected high-risk recursive/force deletion of wide paths (rm -rf)",
216
+ categoryId: "bash.rm-rf",
217
+ builtin: true,
218
+ },
219
+ {
220
+ id: "builtin.bash.win-del",
221
+ enabled: true,
222
+ tools: ["bash"],
223
+ field: "command",
224
+ match: "regex",
225
+ value: "\\b(del|rmdir|rd)\\s+[/\\-][fsq]",
226
+ action: "ask",
227
+ label: "Windows 强制删除 (del/rmdir/rd)",
228
+ labelEn: "Windows force delete (del/rmdir/rd)",
229
+ reason: "检测到高风险的 Windows 强制/递归删除目录命令 (del/rmdir/rd /s /q)",
230
+ reasonEn: "Detected high-risk Windows force/recursive deletion command (del/rmdir/rd /s /q)",
231
+ categoryId: "bash.win-del",
232
+ builtin: true,
233
+ },
234
+ {
235
+ id: "builtin.bash.disk",
236
+ enabled: true,
237
+ tools: ["bash"],
238
+ field: "command",
239
+ match: "regex",
240
+ value: "\\b(mkfs|format\\s+[a-zA-Z]:|dd\\s+if=)",
241
+ action: "ask",
242
+ label: "磁盘格式化/底层写入",
243
+ labelEn: "Disk format / raw block write",
244
+ reason: "检测到磁盘格式化或底层块写入危险命令 (format/mkfs/dd)",
245
+ reasonEn: "Detected dangerous disk formatting or raw block write command (format/mkfs/dd)",
246
+ categoryId: "bash.disk",
247
+ builtin: true,
248
+ },
249
+ {
250
+ id: "builtin.bash.git-destructive",
251
+ enabled: true,
252
+ tools: ["bash"],
253
+ field: "command",
254
+ match: "regex",
255
+ value: "\\bgit\\s+(push\\s+.*?(--force|-[a-zA-Z0-9]*f)\\b|reset\\s+--hard|clean\\s+-[a-zA-Z0-9]*f|branch\\s+-[dD]\\b)",
256
+ action: "ask",
257
+ label: "破坏性 Git 操作",
258
+ labelEn: "Destructive git operation",
259
+ reason: "检测到不可逆的破坏性 Git 操作 (force push / reset --hard / clean -f)",
260
+ reasonEn: "Detected irreversible destructive Git operation (force push / reset --hard / clean -f)",
261
+ categoryId: "bash.git-destructive",
262
+ builtin: true,
263
+ },
264
+ {
265
+ id: "builtin.bash.chmod",
266
+ enabled: true,
267
+ tools: ["bash"],
268
+ field: "command",
269
+ match: "regex",
270
+ value: "\\bchmod\\s+(-R\\s+)?(777|000)\\b",
271
+ action: "ask",
272
+ label: "危险权限修改 (chmod)",
273
+ labelEn: "Dangerous permission change (chmod)",
274
+ reason: "检测到过度开放或全局破坏性的文件权限修改 (chmod 777/000)",
275
+ reasonEn: "Detected overly permissive or globally destructive file permission change (chmod 777/000)",
276
+ categoryId: "bash.chmod",
277
+ builtin: true,
278
+ },
279
+ {
280
+ id: "builtin.bash.system-redirect",
281
+ enabled: true,
282
+ tools: ["bash"],
283
+ field: "command",
284
+ match: "regex",
285
+ value: ">\\s*(\\/etc\\/|\\/boot\\/|C:\\\\Windows)",
286
+ action: "ask",
287
+ label: "写入系统关键目录",
288
+ labelEn: "Write into system directories",
289
+ reason: "检测到重定向写入系统关键目录的危险操作",
290
+ reasonEn: "Detected dangerous redirection writing into critical system directories",
291
+ categoryId: "bash.system-redirect",
292
+ builtin: true,
293
+ },
294
+ {
295
+ id: "builtin.file.sensitive.env",
296
+ enabled: true,
297
+ tools: ["write", "edit", "edit_soft"],
298
+ field: "path",
299
+ match: "regex",
300
+ value: "(^|[\\/\\\\])\\.env(\\.[a-zA-Z0-9_-]+)?$",
301
+ action: "ask",
302
+ label: "敏感配置 (.env)",
303
+ labelEn: "Sensitive config (.env)",
304
+ reason: "尝试修改敏感环境变量/密钥配置文件 (.env)",
305
+ reasonEn: "Attempting to modify sensitive environment/secret configuration (.env)",
306
+ categoryId: "file.sensitive.env",
307
+ builtin: true,
308
+ },
309
+ {
310
+ id: "builtin.file.sensitive.ssh",
311
+ enabled: true,
312
+ tools: ["write", "edit", "edit_soft"],
313
+ field: "path",
314
+ match: "regex",
315
+ value: "(^|[\\/\\\\])(id_rsa|id_ed25519|authorized_keys|known_hosts)$",
316
+ action: "ask",
317
+ label: "SSH 密钥/凭据",
318
+ labelEn: "SSH keys / credentials",
319
+ reason: "尝试修改 SSH 密钥或认证凭据文件",
320
+ reasonEn: "Attempting to modify SSH keys or authentication credentials",
321
+ categoryId: "file.sensitive.ssh",
322
+ builtin: true,
323
+ },
324
+ {
325
+ id: "builtin.file.sensitive.shell",
326
+ enabled: true,
327
+ tools: ["write", "edit", "edit_soft"],
328
+ field: "path",
329
+ match: "regex",
330
+ value: "(^|[\\/\\\\])(\\.bashrc|\\.zshrc|\\.profile|\\.bash_profile)$",
331
+ action: "ask",
332
+ label: "Shell 启动配置",
333
+ labelEn: "Shell profile",
334
+ reason: "尝试修改用户全局 Shell 启动配置文件",
335
+ reasonEn: "Attempting to modify user global Shell profile configuration",
336
+ categoryId: "file.sensitive.shell",
337
+ builtin: true,
338
+ },
339
+ {
340
+ id: "builtin.file.outside-workspace",
341
+ enabled: true,
342
+ tools: ["write", "edit", "edit_soft"],
343
+ field: "path",
344
+ match: "outside_workspace",
345
+ value: "",
346
+ action: "ask",
347
+ label: "工作区外写入",
348
+ labelEn: "Write outside workspace",
349
+ reason: "尝试在工作区外部写入/修改文件",
350
+ reasonEn: "Attempting to write/modify file outside the workspace",
351
+ categoryId: "file.outside-workspace",
352
+ builtin: true,
353
+ },
354
+ ];
355
+ /** 归一化输入规则。脏数据或非法规则返回 null。 */
356
+ export function normalizeApprovalRule(raw) {
357
+ if (!raw || typeof raw !== "object")
358
+ return null;
359
+ const o = raw;
360
+ const id = typeof o.id === "string" ? o.id.trim() : "";
361
+ if (!id || id.length > 120)
362
+ return null;
363
+ const label = typeof o.label === "string" ? o.label.trim() : "";
364
+ if (!label || label.length > 120)
365
+ return null;
366
+ const labelEn = typeof o.labelEn === "string" && o.labelEn.trim() ? o.labelEn.trim() : undefined;
367
+ const reason = typeof o.reason === "string" && o.reason.trim() ? o.reason.trim() : undefined;
368
+ const reasonEn = typeof o.reasonEn === "string" && o.reasonEn.trim() ? o.reasonEn.trim() : undefined;
369
+ const action = o.action === "deny" ? "deny" : o.action === "allow" ? "allow" : "ask";
370
+ const field = o.field === "path" ? "path" : o.field === "params" ? "params" : "command";
371
+ const match = o.match === "glob"
372
+ ? "glob"
373
+ : o.match === "contains"
374
+ ? "contains"
375
+ : o.match === "prefix"
376
+ ? "prefix"
377
+ : o.match === "outside_workspace"
378
+ ? "outside_workspace"
379
+ : "regex";
380
+ const value = typeof o.value === "string" ? o.value : "";
381
+ if (match === "regex") {
382
+ try {
383
+ new RegExp(value);
384
+ }
385
+ catch {
386
+ return null; // 非法正则拒绝
387
+ }
388
+ }
389
+ const tools = Array.isArray(o.tools)
390
+ ? o.tools
391
+ .filter((t) => typeof t === "string" && t.trim().length > 0)
392
+ .map((t) => t.trim().toLowerCase())
393
+ : ["*"];
394
+ return {
395
+ id,
396
+ enabled: o.enabled !== false,
397
+ tools: tools.length > 0 ? tools : ["*"],
398
+ field,
399
+ match,
400
+ value,
401
+ action,
402
+ label,
403
+ ...(labelEn ? { labelEn } : {}),
404
+ ...(reason ? { reason } : {}),
405
+ ...(reasonEn ? { reasonEn } : {}),
406
+ ...(typeof o.categoryId === "string" && o.categoryId.trim() ? { categoryId: o.categoryId.trim() } : {}),
407
+ builtin: o.builtin === true,
408
+ };
409
+ }
410
+ /** 全局审批规则库持久化与操作类。 */
411
+ export class ApprovalRulesStore {
412
+ filePath;
413
+ rules = null;
414
+ lastMtime = 0;
415
+ constructor(filePath) {
416
+ this.filePath = filePath;
417
+ }
418
+ seededPath() {
419
+ return /\.json$/i.test(this.filePath)
420
+ ? this.filePath.replace(/\.json$/i, ".seeded.json")
421
+ : `${this.filePath}.seeded.json`;
422
+ }
423
+ loadSeeded() {
424
+ const out = new Set();
425
+ try {
426
+ const parsed = JSON.parse(readFileSync(this.seededPath(), "utf8"));
427
+ if (Array.isArray(parsed)) {
428
+ for (const item of parsed) {
429
+ if (typeof item === "string" && item)
430
+ out.add(item);
431
+ }
432
+ }
433
+ }
434
+ catch {
435
+ // 文件不存在时返回空集合
436
+ }
437
+ return out;
438
+ }
439
+ saveSeeded(names) {
440
+ try {
441
+ mkdirSync(dirname(this.seededPath()), { recursive: true });
442
+ const tmp = `${this.seededPath()}.${process.pid}.tmp`;
443
+ writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2) + "\n");
444
+ renameSync(tmp, this.seededPath());
445
+ }
446
+ catch {
447
+ // best effort
448
+ }
449
+ }
450
+ load() {
451
+ let currentMtime = 0;
452
+ try {
453
+ if (existsSync(this.filePath)) {
454
+ currentMtime = statSync(this.filePath).mtimeMs;
455
+ }
456
+ }
457
+ catch {
458
+ currentMtime = 0;
459
+ }
460
+ if (this.rules && currentMtime > 0 && currentMtime === this.lastMtime) {
461
+ return this.rules;
462
+ }
463
+ let list = [];
464
+ try {
465
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
466
+ if (Array.isArray(parsed)) {
467
+ list = parsed.map(normalizeApprovalRule).filter((r) => r !== null);
468
+ }
469
+ }
470
+ catch {
471
+ // 文件不存在或格式损坏,以默认规则初始化
472
+ list = DEFAULT_APPROVAL_RULES.map((r) => ({ ...r, tools: [...r.tools] }));
473
+ }
474
+ // 检查播种:老用户新增内置规则自动合并
475
+ const seeded = this.loadSeeded();
476
+ const existingIds = new Set(list.map((r) => r.id));
477
+ let grown = false;
478
+ for (const def of DEFAULT_APPROVAL_RULES) {
479
+ if (!existingIds.has(def.id) && !seeded.has(def.id)) {
480
+ list.push({ ...def, tools: [...def.tools] });
481
+ existingIds.add(def.id);
482
+ grown = true;
483
+ }
484
+ seeded.add(def.id);
485
+ }
486
+ if (grown || seeded.size > 0) {
487
+ this.saveSeeded(seeded);
488
+ }
489
+ this.rules = list;
490
+ this.lastMtime = currentMtime;
491
+ return this.rules;
492
+ }
493
+ persist() {
494
+ try {
495
+ mkdirSync(dirname(this.filePath), { recursive: true });
496
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
497
+ writeFileSync(tmp, JSON.stringify(this.rules ?? [], null, 2) + "\n");
498
+ renameSync(tmp, this.filePath);
499
+ try {
500
+ this.lastMtime = statSync(this.filePath).mtimeMs;
501
+ }
502
+ catch {
503
+ // ignore
504
+ }
505
+ }
506
+ catch {
507
+ // best effort
508
+ }
509
+ }
510
+ /** 获取全部有效规则清单(深拷贝返回)。 */
511
+ list() {
512
+ return this.load().map((r) => ({ ...r, tools: [...r.tools] }));
513
+ }
514
+ /** Upsert 一条规则(同 id 替换,新 id 追加到列表末尾)。返回错误提示,成功返回 null。 */
515
+ upsert(input) {
516
+ const rule = normalizeApprovalRule(input);
517
+ if (!rule)
518
+ return "规则格式非法(缺少名称、工具列表或正则格式错误)";
519
+ const list = this.load();
520
+ const idx = list.findIndex((r) => r.id === rule.id);
521
+ if (idx >= 0) {
522
+ // 内置规则保留 builtin 标记
523
+ if (list[idx].builtin) {
524
+ rule.builtin = true;
525
+ }
526
+ list[idx] = rule;
527
+ }
528
+ else {
529
+ list.push(rule);
530
+ }
531
+ this.persist();
532
+ return null;
533
+ }
534
+ /** 批量重排/替换整份规则(供前端拖拽排序后保存)。 */
535
+ saveAll(inputs) {
536
+ if (!Array.isArray(inputs))
537
+ return "规则列表必须是数组";
538
+ const normalized = [];
539
+ const ids = new Set();
540
+ for (const raw of inputs) {
541
+ const r = normalizeApprovalRule(raw);
542
+ if (!r)
543
+ return "存在格式非法的规则项";
544
+ if (ids.has(r.id))
545
+ return `规则 id 冲突: ${r.id}`;
546
+ ids.add(r.id);
547
+ normalized.push(r);
548
+ }
549
+ // 内置规则是安全底线:整表替换绝不能把它们裁掉(旧客户端 / 并发竞态都
550
+ // 可能送来缺内置规则的清单)。缺失的按默认定义补种、追加到队尾——与
551
+ // load/resetBuiltin 同口径;插队首会改变用户 allow 规则的 first-match
552
+ // 语义。id 命中内置定义的一律强制 builtin 标记(同 upsert 的保护),
553
+ // 防止 remove() 的内置不可删保护被绕过。
554
+ for (const def of DEFAULT_APPROVAL_RULES) {
555
+ if (!ids.has(def.id)) {
556
+ normalized.push({ ...def, tools: [...def.tools] });
557
+ ids.add(def.id);
558
+ }
559
+ else {
560
+ const i = normalized.findIndex((r) => r.id === def.id);
561
+ normalized[i].builtin = true;
562
+ }
563
+ }
564
+ this.rules = normalized;
565
+ this.persist();
566
+ return null;
567
+ }
568
+ /** 删除一条自定义规则(内置规则不可删除)。 */
569
+ remove(id) {
570
+ const list = this.load();
571
+ const idx = list.findIndex((r) => r.id === id);
572
+ if (idx < 0)
573
+ return false;
574
+ if (list[idx].builtin)
575
+ return false; // 内置规则不许直接删除,只允许禁用
576
+ list.splice(idx, 1);
577
+ this.persist();
578
+ return true;
579
+ }
580
+ /** 恢复某条内置规则到系统默认设定。 */
581
+ resetBuiltin(id) {
582
+ const def = DEFAULT_APPROVAL_RULES.find((r) => r.id === id);
583
+ if (!def)
584
+ return false;
585
+ const list = this.load();
586
+ const idx = list.findIndex((r) => r.id === id);
587
+ if (idx >= 0) {
588
+ list[idx] = { ...def, tools: [...def.tools] };
589
+ }
590
+ else {
591
+ list.push({ ...def, tools: [...def.tools] });
592
+ }
593
+ this.persist();
594
+ return true;
595
+ }
596
+ }