ai-helper-agent 2.3.1 → 2.4.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 (74) hide show
  1. package/README.md +13 -3
  2. package/dist/assets/__vite-browser-external-CM3Eme-n.js +1 -0
  3. package/dist/assets/agent-store-Ce3f4Ya3.js +1 -0
  4. package/dist/assets/background.js +104 -80
  5. package/dist/assets/chat-export-CWSUeOo4.js +37 -0
  6. package/dist/assets/chunk-CMxvf4Kt.js +1 -0
  7. package/dist/assets/content.js +159 -156
  8. package/dist/assets/{dist-BQOFgIxk.js → dist-DhUTa_WT.js} +5 -6
  9. package/dist/assets/dist-LQPh1k0y.js +24 -0
  10. package/dist/assets/i18n-ouMgDZ2t.js +49 -0
  11. package/dist/assets/jszip.min-Bm_16osY.js +2 -0
  12. package/dist/assets/lib-33nIefF6.js +220 -0
  13. package/dist/assets/{marked.min.js-08SGy6CO.js → marked.min.js-D2rXgLAQ.js} +7 -8
  14. package/dist/assets/{marked.min.js-loader-4kRZ9iCP.js → marked.min.js-loader-tKH5SQVQ.js} +1 -1
  15. package/dist/assets/model-profiles-6dzdrSS4.js +24 -0
  16. package/dist/assets/network-scope-CNRYXk74.js +1 -0
  17. package/dist/assets/options-Cp1UMkjK.css +1 -0
  18. package/dist/assets/options.js +108 -102
  19. package/dist/assets/{page-snapshot-renderer-CVL6r6Kf.js → page-snapshot-renderer-j72q92KH.js} +2 -3
  20. package/dist/assets/pdf-DnVqBaAN.js +6 -0
  21. package/dist/assets/session-store-CmGs_rhh.js +76 -0
  22. package/dist/assets/side_panel.css +1 -1
  23. package/dist/assets/side_panel.js +958 -1124
  24. package/dist/assets/theme-BRAX5X2F.css +1 -0
  25. package/dist/assets/{modulepreload-polyfill-P2Xu9kJm.js → theme-CLwYsx9o.js} +1 -1
  26. package/dist/assets/utils-9i9g8vW8.js +112 -0
  27. package/dist/assets/version-BrT5Y9A7.js +1 -0
  28. package/dist/assets/xlsx-DGkz7cnk.js +105 -0
  29. package/dist/manifest.json +2 -5
  30. package/dist/offscreen/offscreen.js +7 -7
  31. package/dist/options.html +151 -75
  32. package/dist/side_panel.html +167 -137
  33. package/package.json +1 -1
  34. package/src/config.js +3 -2
  35. package/src/executor.js +99 -18
  36. package/src/locales/en.json +16 -2
  37. package/src/locales/zh.json +16 -2
  38. package/src/memory/injection.js +12 -2
  39. package/src/memory/routes.js +6 -0
  40. package/src/memory/service.js +57 -2
  41. package/src/rag/manager.js +1 -1
  42. package/src/rag/url-guard.js +27 -12
  43. package/src/security.js +433 -31
  44. package/src/server.js +28 -8
  45. package/dist/assets/agent-store-DcAs6YyA.js +0 -2
  46. package/dist/assets/agent-store-DcAs6YyA.js.map +0 -1
  47. package/dist/assets/background.js.map +0 -1
  48. package/dist/assets/dist-BQOFgIxk.js.map +0 -1
  49. package/dist/assets/i18n-C4jSRoCH.js +0 -50
  50. package/dist/assets/i18n-C4jSRoCH.js.map +0 -1
  51. package/dist/assets/jszip.min-BXqW2-9v.js +0 -3
  52. package/dist/assets/jszip.min-BXqW2-9v.js.map +0 -1
  53. package/dist/assets/lib-Bi7BFuND.js +0 -221
  54. package/dist/assets/lib-Bi7BFuND.js.map +0 -1
  55. package/dist/assets/marked.min.js-08SGy6CO.js.map +0 -1
  56. package/dist/assets/model-profiles-Dl_MsXWa.js +0 -25
  57. package/dist/assets/model-profiles-Dl_MsXWa.js.map +0 -1
  58. package/dist/assets/options-8-nu3P5K.css +0 -1
  59. package/dist/assets/options.js.map +0 -1
  60. package/dist/assets/page-snapshot-renderer-CVL6r6Kf.js.map +0 -1
  61. package/dist/assets/pdf-HiJ0icvU.js +0 -7
  62. package/dist/assets/pdf-HiJ0icvU.js.map +0 -1
  63. package/dist/assets/rolldown-runtime-C0FnF6B9.js +0 -1
  64. package/dist/assets/session-store-CzVQCgQ_.js +0 -77
  65. package/dist/assets/session-store-CzVQCgQ_.js.map +0 -1
  66. package/dist/assets/side_panel.js.map +0 -1
  67. package/dist/assets/version-CNG2KGnJ.js +0 -2
  68. package/dist/assets/version-CNG2KGnJ.js.map +0 -1
  69. package/dist/assets/xlsx-BHKh7vjA.js +0 -106
  70. package/dist/assets/xlsx-BHKh7vjA.js.map +0 -1
  71. package/dist/icons/badges/license-MIT.svg +0 -21
  72. package/dist/icons/badges/platform.svg +0 -21
  73. package/dist/icons/badges/prs-welcome.svg +0 -21
  74. package/dist/icons/badges/version.svg +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "description": "AI Helper Agent - Local file access and system command execution proxy for Chrome extension / AI Helper 代理服务,为 Chrome 扩展提供本地文件读写和系统命令执行",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
package/src/config.js CHANGED
@@ -20,8 +20,9 @@ const DEFAULTS = {
20
20
  pairCodeTTL: 30,
21
21
  commandTimeout: 300000,
22
22
  fileMaxSize: 50 * 1024 * 1024,
23
- // RAG URL 导入内网访问策略:allowedPrivateHosts 非空时仅放行白名单命中(总开关不生效);
24
- // 为空时由 allowPrivateNetwork 决定;缺省均关闭(默认拒绝内网),公网访问不受影响
23
+ // RAG URL 导入内网访问策略(B2 AND 闸,与扩展侧 network-scope.js 同序):
24
+ // allowPrivateNetwork 开启 **且** allowedPrivateHosts 命中才放行内网;关闸时白名单不生效,
25
+ // 开闸但白名单为空仍拒绝;缺省均关闭(默认拒绝内网),公网访问不受影响
25
26
  ragUrlIngest: { allowPrivateNetwork: false, allowedPrivateHosts: [] },
26
27
  // 长期记忆语义召回(hybrid recall)的 embedding 配置:缺省关闭 → recall 只走关键词通道(Tier A),
27
28
  // 不引入任何新的默认网络调用。mode: 'openai-compat'(远端,零依赖)| 'local'(本地模型,需 RAG 依赖)。
package/src/executor.js CHANGED
@@ -162,6 +162,29 @@ function generateExecId() {
162
162
  return crypto.randomBytes(8).toString('hex');
163
163
  }
164
164
 
165
+ // ===== 超时策略(决策权威在 Agent 端:不受插件端 SW 生命周期影响) =====
166
+ export const MAX_TIMEOUT_EXTENSIONS = 5;
167
+ export const TOTAL_BUDGET_CAP_MS = 600000; // 与插件端 toolTimeout 上限对齐
168
+
169
+ /**
170
+ * 命令总超时到期时的裁决纯函数(无副作用,便于单测)
171
+ * @param {{ outputInPeriod: boolean, extensions: number, elapsedMs: number, baseTimeout: number }} state
172
+ * outputInPeriod: 本预算期内是否收到过 stdout/stderr 输出(输出流 = 心跳)
173
+ * @param {number} [capMs] 总预算封顶
174
+ * @param {number} [maxExtensions] 最大延长次数
175
+ * @returns {{ action: 'extend'|'kill', extensions: number, nextTimeoutMs?: number }}
176
+ */
177
+ export function decideTimeoutAction(state, capMs = TOTAL_BUDGET_CAP_MS, maxExtensions = MAX_TIMEOUT_EXTENSIONS) {
178
+ const { outputInPeriod, extensions, elapsedMs, baseTimeout } = state;
179
+ if (extensions >= maxExtensions) return { action: 'kill', extensions };
180
+ // 本预算期完全无输出 → 视为挂起/静默失败,到点即杀,不再放大等待窗口
181
+ if (!outputInPeriod) return { action: 'kill', extensions };
182
+ const currentBudget = Math.min(baseTimeout * (1 + extensions), capMs);
183
+ const nextBudget = Math.min(baseTimeout * (2 + extensions), capMs);
184
+ if (nextBudget <= currentBudget) return { action: 'kill', extensions }; // 已触及总封顶
185
+ return { action: 'extend', extensions: extensions + 1, nextTimeoutMs: nextBudget - elapsedMs };
186
+ }
187
+
165
188
  /**
166
189
  * 执行命令并通过 WebSocket 流式输出
167
190
  * @param {string} command - 完整命令字符串
@@ -169,13 +192,18 @@ function generateExecId() {
169
192
  * @param {Object} wsClient - 单个 WebSocket 客户端(用于流式输出)
170
193
  * @param {Function} onComplete - 完成回调 ({ execId, exitCode, killed, stdout?, stderr? })
171
194
  * @param {boolean} [collectOutput=false] - 是否收集完整 stdout/stderr 到内存
195
+ * @param {{ timeoutMs?: number }} [execOpts] - 受管模式协商:调用方显式传 timeoutMs 即接管超时权威
172
196
  * @returns {string} execId
173
197
  */
174
- function executeCommand(command, cwd, wsClient, onComplete, collectOutput = false) {
198
+ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = false, execOpts = {}) {
175
199
  const config = loadConfig();
176
200
  const workdir = cwd || config.workdir;
177
201
  const execId = generateExecId();
178
- const timeout = config.commandTimeout || 300000;
202
+ // 协议协商:显式 timeoutMs → 受管模式(定时器与 WS 连接数解耦,延长由策略函数裁决);
203
+ // 未传 → 旧语义(commandTimeout + 断连清定时器),保证新 agent × 旧插件行为不变
204
+ const requestedTimeout = Number(execOpts.timeoutMs) > 0 ? Math.round(Number(execOpts.timeoutMs)) : null;
205
+ const timeout = requestedTimeout || config.commandTimeout || 300000;
206
+ const managed = requestedTimeout != null;
179
207
 
180
208
  const { shell, args } = getShellForExec();
181
209
  // detached 的取舍(按平台 + shell 类型):
@@ -209,14 +237,42 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
209
237
  // 存储到 entry 中供 clearTimers 读取
210
238
  // stdoutBuf/stderrBuf 始终收集,用于回放给延迟连接的 WS 客户端
211
239
  // command/startTime 用于状态详情面板展示运行中进程
212
- const entry = { process: proc, wsClients, stdoutBuf: '', stderrBuf: '', truncStdout: false, truncStderr: false, command, startTime: Date.now() };
240
+ const entry = {
241
+ process: proc, wsClients, stdoutBuf: '', stderrBuf: '',
242
+ truncStdout: false, truncStderr: false, command, startTime: Date.now(),
243
+ baseTimeout: timeout, managed, extensions: 0, outputInPeriod: false,
244
+ detached: false, timedOut: false,
245
+ };
213
246
  runningProcesses.set(execId, entry);
214
247
 
215
- // 超时控制
216
- entry.timeoutId = setTimeout(() => {
217
- killed = true;
218
- killProcess(execId);
219
- }, timeout);
248
+ // 超时控制:受管模式到期先由策略函数裁决(期内有输出才延长,静默即杀);旧模式到期直接杀
249
+ function armTimeout(ms) {
250
+ entry.timeoutId = setTimeout(() => {
251
+ entry.timeoutId = null;
252
+ if (!entry.managed) {
253
+ killed = true;
254
+ entry.timedOut = true;
255
+ killProcess(execId);
256
+ return;
257
+ }
258
+ const d = decideTimeoutAction({
259
+ outputInPeriod: entry.outputInPeriod,
260
+ extensions: entry.extensions,
261
+ elapsedMs: Date.now() - entry.startTime,
262
+ baseTimeout: entry.baseTimeout,
263
+ });
264
+ if (d.action === 'extend') {
265
+ entry.extensions = d.extensions;
266
+ entry.outputInPeriod = false;
267
+ armTimeout(d.nextTimeoutMs);
268
+ } else {
269
+ killed = true;
270
+ entry.timedOut = true;
271
+ killProcess(execId);
272
+ }
273
+ }, ms);
274
+ }
275
+ armTimeout(timeout);
220
276
 
221
277
  // 广播消息给所有连接的 WS 客户端
222
278
  function broadcast(data) {
@@ -241,6 +297,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
241
297
  // stdout 流式输出
242
298
  proc.stdout.on('data', (chunk) => {
243
299
  const str = chunk.toString();
300
+ entry.outputInPeriod = true;
244
301
  if (collectOutput) stdoutCollected += str;
245
302
  const truncState = { truncated: entry.truncStdout };
246
303
  entry.stdoutBuf = appendWithLimit(entry.stdoutBuf, str, truncState);
@@ -254,6 +311,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
254
311
  // stderr 流式输出
255
312
  proc.stderr.on('data', (chunk) => {
256
313
  const str = chunk.toString();
314
+ entry.outputInPeriod = true;
257
315
  if (collectOutput) stderrCollected += str;
258
316
  const truncState = { truncated: entry.truncStderr };
259
317
  entry.stderrBuf = appendWithLimit(entry.stderrBuf, str, truncState);
@@ -270,7 +328,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
270
328
  finished = true;
271
329
  clearTimers();
272
330
  const finalExitCode = typeof exitCode === 'number' ? exitCode : -1;
273
- broadcast({ type: 'exit', exitCode: finalExitCode, execId, killed });
331
+ broadcast({ type: 'exit', exitCode: finalExitCode, execId, killed, timedOut: entry.timedOut });
274
332
  // 移入已完成缓存,解决竞态条件:延迟连接的 WS 客户端可回放输出
275
333
  const cleanupTimeout = setTimeout(() => {
276
334
  completedProcesses.delete(execId);
@@ -280,6 +338,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
280
338
  stderrBuf: entry.stderrBuf,
281
339
  exitCode: finalExitCode,
282
340
  killed,
341
+ timedOut: entry.timedOut,
283
342
  cleanupTimeout
284
343
  });
285
344
  runningProcesses.delete(execId);
@@ -330,10 +389,11 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
330
389
  * 同步执行命令(阻塞等待完成,返回完整输出)
331
390
  * @returns {Promise<{execId, exitCode, stdout, stderr, killed, error?}>}
332
391
  */
333
- function executeCommandSync(command, cwd) {
392
+ function executeCommandSync(command, cwd, timeoutMs) {
334
393
  return new Promise((resolve) => {
335
394
  const config = loadConfig();
336
- const timeout = config.commandTimeout || 300000;
395
+ const requestedTimeout = Number(timeoutMs) > 0 ? Math.round(Number(timeoutMs)) : null;
396
+ const timeout = requestedTimeout || config.commandTimeout || 300000;
337
397
 
338
398
  let resolved = false;
339
399
  let syncTimeoutId = null;
@@ -344,14 +404,19 @@ function executeCommandSync(command, cwd) {
344
404
  resolved = true;
345
405
  resolve(result);
346
406
  }
347
- }, true);
407
+ }, true, { timeoutMs });
348
408
 
349
- // 超时兜底(超时后额外 5s 用于 kill 完成)
409
+ // 兜底(超时后额外 5s 用于 kill 完成):内层策略失效时到点杀进程并回收已收集输出,不再返回空串
350
410
  syncTimeoutId = setTimeout(() => {
351
411
  if (!resolved) {
352
412
  resolved = true;
353
413
  killProcess(execId);
354
- resolve({ execId, exitCode: -1, killed: true, stdout: '', stderr: '', error: translate(currentLang, 'error.commandTimeout') });
414
+ const e = runningProcesses.get(execId);
415
+ resolve({
416
+ execId, exitCode: -1, killed: true,
417
+ stdout: e ? e.stdoutBuf : '', stderr: e ? e.stderrBuf : '',
418
+ error: translate(currentLang, 'error.commandTimeout'),
419
+ });
355
420
  }
356
421
  }, timeout + 5000);
357
422
  });
@@ -379,7 +444,7 @@ function addWsClient(execId, wsClient) {
379
444
  if (completed.error) {
380
445
  wsClient.send(JSON.stringify({ type: 'error', error: completed.error, execId }));
381
446
  }
382
- wsClient.send(JSON.stringify({ type: 'exit', exitCode: completed.exitCode, execId, killed: completed.killed }));
447
+ wsClient.send(JSON.stringify({ type: 'exit', exitCode: completed.exitCode, execId, killed: completed.killed, timedOut: completed.timedOut === true }));
383
448
  clearTimeout(completed.cleanupTimeout);
384
449
  completedProcesses.delete(execId);
385
450
  return true;
@@ -397,8 +462,10 @@ function disconnectWsClient(execId, wsClient) {
397
462
 
398
463
  entry.wsClients.delete(wsClient);
399
464
 
400
- // 所有客户端都断开了 → 取消超时,进程继续运行
401
- if (entry.wsClients.size === 0) {
465
+ // 旧语义:所有客户端断开 → 取消超时,进程自由运行(挂起型服务依赖此行为)。
466
+ // 受管模式:断开可能只是插件端 SW 被 Chrome 杀掉/网络闪断,绝不可解除超时权威,
467
+ // 仅显式 detach(entry.detached)才豁免——连接状态不再反推用户意图。
468
+ if (entry.wsClients.size === 0 && (!entry.managed || entry.detached)) {
402
469
  if (entry.timeoutId) {
403
470
  clearTimeout(entry.timeoutId);
404
471
  entry.timeoutId = null;
@@ -411,6 +478,20 @@ function disconnectWsClient(execId, wsClient) {
411
478
  return true;
412
479
  }
413
480
 
481
+ /**
482
+ * 显式声明 daemon 化:取消超时定时器,进程继续运行(插件端空闲挂起分支调用)
483
+ */
484
+ function detachProcess(execId) {
485
+ const entry = runningProcesses.get(execId);
486
+ if (!entry) return false;
487
+ entry.detached = true;
488
+ if (entry.timeoutId) {
489
+ clearTimeout(entry.timeoutId);
490
+ entry.timeoutId = null;
491
+ }
492
+ return true;
493
+ }
494
+
414
495
  /**
415
496
  * 停止正在运行的命令(跨平台兼容,递归终止全部子进程)
416
497
  * - macOS/Linux: SIGTERM to process group(-pid)→ 5s → SIGKILL to process group
@@ -486,4 +567,4 @@ function getRunningProcesses() {
486
567
  return list;
487
568
  }
488
569
 
489
- export { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses, pickShell };
570
+ export { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, detachProcess, getRunningProcesses, pickShell };
@@ -107,7 +107,7 @@
107
107
  "err_pathNotAllowed": "File path not allowed: {reason}",
108
108
  "err_invalidUrl": "Invalid URL (http/https required)",
109
109
  "err_urlFetchFailed": "URL fetch failed: HTTP {status}",
110
- "err_urlPrivateBlocked": "Access to private network address blocked: {host} (configure the allowlist or enable the private-network switch on the agent)",
110
+ "err_urlPrivateBlocked": "Access to private network address blocked: {host} (requires the private-network switch enabled AND the host in the allowlist; either alone is not sufficient)",
111
111
  "err_urlTooLarge": "URL response exceeds the size limit ({limit})",
112
112
  "err_urlTooManyRedirects": "Too many URL redirects (limit: {max})",
113
113
  "err_contentTooLarge": "Import content exceeds the size limit ({limit})",
@@ -168,6 +168,7 @@
168
168
  "commandInvalid": "Invalid command",
169
169
  "commandBlocked": "High-risk command blocked: \"{command}\"",
170
170
  "cmdSudo": "Command requires administrator privileges (sudo)",
171
+ "cmdDoas": "Command requires privilege escalation (doas/runas)",
171
172
  "cmdNpmGlobal": "Global npm package installation",
172
173
  "cmdPip": "pip install/uninstall package",
173
174
  "cmdChmod777": "Recursively change permissions to 777",
@@ -186,7 +187,18 @@
186
187
  "cmdReg": "Modify registry (reg)",
187
188
  "cmdNet": "User/group management (net)",
188
189
  "cmdSc": "Service management (sc)",
189
- "cmdTaskkillF": "Force kill process (taskkill /f)"
190
+ "cmdTaskkillF": "Force kill process (taskkill /f)",
191
+ "cmdPsRemoveItem": "PowerShell recursive/forced delete (Remove-Item)",
192
+ "cmdPsEncoded": "Execute base64-encoded PowerShell script",
193
+ "cmdCmdSubstitution": "Command contains command substitution ($( ) or backticks)",
194
+ "cmdDdWrite": "Write raw file content via dd",
195
+ "cmdShred": "Destroy file irrecoverably (shred)",
196
+ "cmdTruncate": "Truncate file content to zero (truncate)",
197
+ "cmdChmodDestroy": "Change permissions making file unusable (chmod 000/777/a-rwx)",
198
+ "cmdChown": "Change file owner (chown)",
199
+ "cmdNetwork": "Initiate outbound network connection",
200
+ "cmdKillProcess": "Terminate processes (kill/pkill/killall)",
201
+ "cmdSystemService": "Modify system services / cron / kernel parameters"
190
202
  },
191
203
  "trash": {
192
204
  "moveFailed": "Move to trash failed: {message}",
@@ -305,6 +317,8 @@
305
317
  "readFailed": "Failed to read memory file: {error}",
306
318
  "writeFailed": "Failed to write memory file: {error}",
307
319
  "parseFailed": "Memory file parse failed: {error}",
320
+ "injectionPatternBlocked": "Memory content appears to contain prompt-injection instructions (e.g. ignore previous instructions / override system prompt); write rejected",
321
+ "rateLimited": "Memory writes in this session are too frequent; anti-poisoning rate limit reached, please retry later",
308
322
  "updatedDuplicate": "Updated existing memory: {id} (content identical, merged)",
309
323
  "added": "Added memory: {id} ({type}){warning}",
310
324
  "nearLimitWarning": "\n⚠️ Memory count approaching limit (facts: {facts}/{maxFacts}, summaries: {summaries}/{maxSummaries}). Consider calling agent_memory action=manage to review and clean up.",
@@ -107,7 +107,7 @@
107
107
  "err_pathNotAllowed": "文件路径不允许访问:{reason}",
108
108
  "err_invalidUrl": "无效的 URL(须为 http/https)",
109
109
  "err_urlFetchFailed": "URL 抓取失败: HTTP {status}",
110
- "err_urlPrivateBlocked": "访问内网地址被拒绝:{host}(可在代理端配置白名单或开启允许内网开关)",
110
+ "err_urlPrivateBlocked": "访问内网地址被拒绝:{host}(需开启允许内网开关并将目标主机加入白名单,两者缺一不可)",
111
111
  "err_urlTooLarge": "URL 响应体超过大小限制({limit})",
112
112
  "err_urlTooManyRedirects": "URL 重定向次数超过上限({max})",
113
113
  "err_contentTooLarge": "导入内容超过大小限制({limit})",
@@ -168,6 +168,7 @@
168
168
  "commandInvalid": "命令无效",
169
169
  "commandBlocked": "高危命令被拦截: \"{command}\"",
170
170
  "cmdSudo": "命令需要管理员权限 (sudo)",
171
+ "cmdDoas": "命令需要提权 (doas/runas)",
171
172
  "cmdNpmGlobal": "全局安装 npm 包",
172
173
  "cmdPip": "pip 安装/卸载包",
173
174
  "cmdChmod777": "递归修改权限为 777",
@@ -186,7 +187,18 @@
186
187
  "cmdReg": "修改注册表 (reg)",
187
188
  "cmdNet": "用户/用户组管理 (net)",
188
189
  "cmdSc": "服务管理 (sc)",
189
- "cmdTaskkillF": "强制结束进程 (taskkill /f)"
190
+ "cmdTaskkillF": "强制结束进程 (taskkill /f)",
191
+ "cmdPsRemoveItem": "PowerShell 递归/强制删除 (Remove-Item)",
192
+ "cmdPsEncoded": "执行 base64 编码的 PowerShell 脚本",
193
+ "cmdCmdSubstitution": "命令中包含命令替换($( … ) 或反引号)",
194
+ "cmdDdWrite": "使用 dd 裸写文件内容",
195
+ "cmdShred": "不可恢复地销毁文件 (shred)",
196
+ "cmdTruncate": "清空文件内容 (truncate)",
197
+ "cmdChmodDestroy": "修改权限致文件不可用 (chmod 000/777/a-rwx)",
198
+ "cmdChown": "修改文件属主 (chown)",
199
+ "cmdNetwork": "向外发起网络连接",
200
+ "cmdKillProcess": "结束进程 (kill/pkill/killall)",
201
+ "cmdSystemService": "修改系统服务/计划任务/内核参数"
190
202
  },
191
203
  "trash": {
192
204
  "moveFailed": "移至回收站失败: {message}",
@@ -305,6 +317,8 @@
305
317
  "readFailed": "读取记忆文件失败: {error}",
306
318
  "writeFailed": "写入记忆文件失败: {error}",
307
319
  "parseFailed": "记忆文件解析失败: {error}",
320
+ "injectionPatternBlocked": "记忆内容疑似包含提示注入指令(如忽略既有指令 / 覆盖系统提示),已拒绝写入",
321
+ "rateLimited": "本会话记忆写入过于频繁,已触发防批量投毒上限,请稍后再试",
308
322
  "updatedDuplicate": "已更新已有记忆: {id}(内容相同,已合并)",
309
323
  "added": "已添加记忆: {id} ({type}){warning}",
310
324
  "nearLimitWarning": "\n⚠️ 记忆数量接近上限(事实: {facts}/{maxFacts}, 摘要: {summaries}/{maxSummaries}),建议调用 agent_memory action=manage 进行审查整理。",
@@ -11,6 +11,7 @@ export const MEMORY_INJECTION_CONFIG = {
11
11
  itemOverheadChars: 50, // 每条渲染开销近似(序号、[重要性: N]、(标签: ...))
12
12
  maxTopicTags: 8, // 索引提示最多展示的主题标签数
13
13
  maxTagLength: 12, // 单个主题标签最大长度(超出截断)
14
+ tier1MaxChars: 500, // Tier1 常驻单条长度上限(P1-3:防大载荷永久驻留 system prompt)
14
15
  };
15
16
 
16
17
  /**
@@ -85,9 +86,18 @@ export function selectMemoriesForInjection(facts, summaries, config = MEMORY_INJ
85
86
  const summaryList = Array.isArray(summaries) ? summaries : [];
86
87
  const threshold = config.alwaysInjectImportance;
87
88
 
88
- // Tier1:常驻(不占预算),内部按价值降序
89
+ // Tier1:常驻(不占预算),内部按价值降序。
90
+ // P1-3 来源分级:仅 origin==='user'(用户在面板手动新建/编辑)可进永久常驻;
91
+ // origin 缺失(旧数据)或 'model'(模型经工具/自动压缩写入)一律降入 Tier2/Tier3——
92
+ // 即便模型把投毒记忆的 importance 顶到 ≥8 也无法常驻,只能按需/预算召回。
93
+ // 单条长度 > tier1MaxChars 也不进 Tier1(防大载荷永久驻留);未配置该字段时不限制(自定义 config 兼容)。
94
+ const tier1MaxChars = config.tier1MaxChars ?? Infinity;
95
+ const isTier1Eligible = (m) =>
96
+ m.origin === 'user'
97
+ && (m.importance || 5) >= threshold
98
+ && (typeof m.content === 'string' ? m.content.length <= tier1MaxChars : false);
89
99
  const tier1 = factList
90
- .filter(m => (m.importance || 5) >= threshold)
100
+ .filter(isTier1Eligible)
91
101
  .sort((a, b) => calcMemoryValue(b, now) - calcMemoryValue(a, now));
92
102
  const tier1Set = new Set(tier1);
93
103
 
@@ -160,6 +160,9 @@ export async function memoryDispatch(req, res, pathname, url, t, requestBody = {
160
160
  }
161
161
 
162
162
  const body = requestBody || {};
163
+ // P1-3:记忆来源以 HTTP header 为权威——模型只能控制 body(经 tool-memory.js 原样透传),
164
+ // 控制不了 background 发请求时的 header;仅 options 面板的 agentApi 会带 X-Memory-Caller: user。
165
+ const callerOrigin = String(req?.headers?.['x-memory-caller'] || '').toLowerCase() === 'user' ? 'user' : 'model';
163
166
 
164
167
  try {
165
168
  let data;
@@ -170,6 +173,9 @@ export async function memoryDispatch(req, res, pathname, url, t, requestBody = {
170
173
  } else if (method === 'backfillVectors') {
171
174
  // 不接受请求体:只做「按当前配置把全部记忆补进向量缓存」,t 用于渲染跳过原因
172
175
  data = await service.backfillVectors(t);
176
+ } else if (method === 'store') {
177
+ // P1-3:store 传入可信 ctx(源于上面的 header 判定),忽略 body 里模型可伪造的 origin
178
+ data = await service.store(body, t, { origin: callerOrigin });
173
179
  } else {
174
180
  data = await service[method](body, t);
175
181
  }
@@ -68,6 +68,42 @@ function normalizeImportance(value) {
68
68
  return Math.min(IMPORTANCE_MAX, Math.max(IMPORTANCE_MIN, Math.round(n)));
69
69
  }
70
70
 
71
+ // P1-3 记忆来源分级:origin 记录每条记忆的可信来源,用于注入侧决定能否进 Tier1 常驻。
72
+ // 关键:origin 由「谁发的请求」在运行时判定,模型不能自封——agent_memory 的 body 经扩展
73
+ // tool-memory.js 以 { ...args } 原样透传,模型可往 args 塞任意字段,故 origin 绝不能读 body,
74
+ // 只认调用方透传进来的可信 ctx(源于 HTTP header)。缺失一律按 'model'(默认不可信 → 降出 Tier1)。
75
+ const MEMORY_ORIGIN_USER = 'user'; // 用户在面板(options)手动新建/编辑
76
+ const MEMORY_ORIGIN_MODEL = 'model'; // 模型经 agent_memory 工具或自动压缩写入
77
+
78
+ // 写入侧 fail-closed 拦截:命中提示注入短语的记忆直接拒写(中英常见越狱载荷形态)。
79
+ // 宁可少存一条,不可把可疑指令放进会回注 system prompt 的记忆库。
80
+ const MEMORY_INJECTION_PATTERNS = [
81
+ /ignore\s+(all\s+)?previous\s+instructions/i,
82
+ /ignore\s+the\s+(above|system)\s+prompt/i,
83
+ /disregard\s+(all\s+)?(previous|above)/i,
84
+ /system\s*(prompt|override)\s*[::]/i,
85
+ /you\s+are\s+now\s+(in\s+)?(developer|jailbreak|dan)\s*mode/i,
86
+ /忽略(以上|之前|上面)(的)?(所有)?指令/,
87
+ /无视.{0,6}(系统|之前).{0,6}(提示|指令)/,
88
+ ];
89
+
90
+ function detectInjectionPattern(content) {
91
+ if (typeof content !== 'string' || !content) return null;
92
+ return MEMORY_INJECTION_PATTERNS.find((re) => re.test(content)) || null;
93
+ }
94
+
95
+ // 频率上限:同一 (origin:sessionId) 在进程存活期内的写入条数上限(防批量投毒)。
96
+ // 进程内计数、daemon 重启即清零——best-effort 兜底,不是持久配额。
97
+ const MEMORY_STORE_RATE_LIMIT = 20;
98
+ const storeRateCounter = new Map();
99
+
100
+ function bumpStoreRate(origin, sessionId) {
101
+ const key = `${origin}:${sessionId || 'unknown'}`;
102
+ const next = (storeRateCounter.get(key) || 0) + 1;
103
+ storeRateCounter.set(key, next);
104
+ return next;
105
+ }
106
+
71
107
  /**
72
108
  * 词面命中门槛:整词/整段命中可直接成命中;只碰碎块时需单元素 ≥ 2 才算。
73
109
  * 这一条是碎 bigram 噪声的真正出口(旧实现里「写网」「个国」这类随机 2 字块也能凑出分)。
@@ -231,8 +267,10 @@ export function createMemoryService({
231
267
  * store:新增/更新/删除记忆(迁移自 executeAgentMemoryStore tool-memory.js:215-347)
232
268
  * args.action 为子动作 add/update/delete(扩展转发层已将 subAction 映射到 action)
233
269
  */
234
- async function store(args, t) {
270
+ async function store(args, t, ctx = {}) {
235
271
  const { action, type, category, content, title, tags, importance, memoryId, sourceSessionId } = args;
272
+ // P1-3:origin 只认调用方透传的可信 ctx(源于 HTTP header),绝不从 args 取——模型可伪造 body。
273
+ const origin = ctx && ctx.origin === MEMORY_ORIGIN_USER ? MEMORY_ORIGIN_USER : MEMORY_ORIGIN_MODEL;
236
274
 
237
275
  if (!action) return { success: false, error: t('memory.missingAction') };
238
276
  // delete 不需要 type(memoryId 全局唯一),add/update 需要
@@ -252,6 +290,11 @@ export function createMemoryService({
252
290
  return { success: false, error: t('memory.invalidImportance', { value: String(importance) }) };
253
291
  }
254
292
 
293
+ // P1-3 写入侧 fail-closed:命中提示注入短语的内容(add/update)直接拒写
294
+ if ((action === 'add' || action === 'update') && content !== undefined && detectInjectionPattern(content)) {
295
+ return { success: false, error: t('memory.injectionPatternBlocked'), code: 'injection_blocked' };
296
+ }
297
+
255
298
  return withMemoryLock(async () => {
256
299
  const readResult = await readMemoryFile();
257
300
  if (!readResult.success) return { success: false, error: t('memory.readFailed', { error: readResult.error }) };
@@ -261,6 +304,11 @@ export function createMemoryService({
261
304
  const targetArray = type === 'fact' ? memoryData.facts : memoryData.summaries;
262
305
 
263
306
  if (action === 'add') {
307
+ // P1-3 频率上限:仅对带真实 sessionId 的会话计数限流(贴合“同一会话内”语义);
308
+ // 无 sessionId 的写入(如自动压缩)不归入会话桶,避免全局误伤。
309
+ if (sourceSessionId && bumpStoreRate(origin, sourceSessionId) > MEMORY_STORE_RATE_LIMIT) {
310
+ return { success: false, error: t('memory.rateLimited'), code: 'rate_limited' };
311
+ }
264
312
  const newMemory = {
265
313
  id: generateMemoryId(type),
266
314
  type,
@@ -272,7 +320,8 @@ export function createMemoryService({
272
320
  lastAccessAt: null,
273
321
  createdAt: now,
274
322
  updatedAt: now,
275
- sourceSessionId: sourceSessionId || null
323
+ sourceSessionId: sourceSessionId || null,
324
+ origin
276
325
  };
277
326
  if (type === 'summary' && title) {
278
327
  newMemory.title = title;
@@ -285,6 +334,9 @@ export function createMemoryService({
285
334
  duplicate.tags = tags || duplicate.tags;
286
335
  duplicate.importance = importanceValue ?? duplicate.importance;
287
336
  duplicate.sourceSessionId = sourceSessionId || duplicate.sourceSessionId;
337
+ // P1-3:用户编辑可把来源升为 user;模型写入永不改判已有来源(缺失才补 model)
338
+ if (origin === MEMORY_ORIGIN_USER) duplicate.origin = MEMORY_ORIGIN_USER;
339
+ else if (duplicate.origin === undefined) duplicate.origin = MEMORY_ORIGIN_MODEL;
288
340
  const writeResult = await writeMemoryFile(memoryData);
289
341
  if (!writeResult.success) return { success: false, error: t('memory.writeFailed', { error: writeResult.error }) };
290
342
  queueVectorBackfill(duplicate.id, duplicate);
@@ -342,6 +394,9 @@ export function createMemoryService({
342
394
  if (importanceValue !== null) existing.importance = importanceValue;
343
395
  if (category !== undefined) existing.category = category;
344
396
  if (type === 'summary' && title !== undefined) existing.title = title;
397
+ // P1-3:用户编辑 → 升为 user;模型更新 → 保留原来源(缺失才补 model),绝不提权
398
+ if (origin === MEMORY_ORIGIN_USER) existing.origin = MEMORY_ORIGIN_USER;
399
+ else if (existing.origin === undefined) existing.origin = MEMORY_ORIGIN_MODEL;
345
400
  existing.updatedAt = now;
346
401
 
347
402
  const writeResult = await writeMemoryFile(memoryData);
@@ -799,7 +799,7 @@ export class RagManager {
799
799
 
800
800
  /**
801
801
  * 导入 URL 内容(HTML 提取正文 / 文本直取 / PDF 下载解析)
802
- * 安全:逐跳重定向校验 + 私网拦截(白名单优先/总开关,见 url-guard.js)+ 响应体大小上限
802
+ * 安全:逐跳重定向校验 + 私网拦截(B2 AND 闸:开关开且白名单命中才放行,见 url-guard.js)+ 响应体大小上限
803
803
  * @param {string} collectionId
804
804
  * @param {{url: string, metadata?: object, chunkConfig?: object}} input
805
805
  */
@@ -1,8 +1,8 @@
1
1
  // agent/src/rag/url-guard.js - RAG URL 导入的 SSRF 防护
2
2
  // 三层防线:
3
3
  // 1. 目标地址干预:DNS 解析后按真实 IP 判断私网/保留段,决定是否放行
4
- // 放行优先级:白名单非空 → 仅白名单命中放行(总开关不生效,白名单优先);
5
- // 白名单为空 → 由总开关 allowPrivateNetwork 决定(缺省关闭)
4
+ // 内网放行采用「总闸 + 白名单」的 B2 AND 闸语义(与扩展侧 src/shared/network-scope.js 同序):
5
+ // 开关开 **且** 白名单命中才放行;关闸时白名单命中也拒绝;开闸但白名单为空仍拒绝
6
6
  // 2. 重定向逐跳校验:redirect: manual 手动跟随,每一跳都重新过第 1 层(防合法地址 302 跳内网)
7
7
  // 3. 响应体大小上限:流式累计,超限立即中断(防超大响应 OOM)
8
8
  // 注:DNS 校验与实际连接存在二次解析的理论窗口(DNS rebinding),
@@ -66,16 +66,30 @@ export function matchesHostAllowlist(hostname, allowlist) {
66
66
  }
67
67
 
68
68
  /**
69
- * 私网目标放行决策
70
- * 优先级:白名单非空 → 仅白名单命中放行(总开关不生效);
71
- * 白名单为空(或仅空白条目)→ 由总开关 allowPrivateNetwork 决定
69
+ * 私网目标放行决策(B2 AND 闸,判定顺序与扩展侧 src/shared/network-scope.js 的 decideNetworkAccess 一致):
70
+ * 1. 总开关关闭 → 拒绝(白名单命中也无效,白名单不被自动清空)
71
+ * 2. 开关开启 + 白名单为空(或仅空白条目)→ 拒绝("启用能力"不等于"放行全部内网")
72
+ * 3. 开关开启 + 白名单非空 → 仅放行命中条目
73
+ * @returns {{ allowed: boolean, reason: string }} reason: gateClosed / noAllowlistedHost / hostNotAllowlisted / allowlisted
72
74
  */
73
- export function isPrivateTargetAllowed(hostname, policy) {
75
+ export function decidePrivateAccess(hostname, policy) {
74
76
  const p = policy && typeof policy === 'object' ? policy : {};
75
- const allowlist = Array.isArray(p.allowedPrivateHosts) ? p.allowedPrivateHosts : [];
76
- const hasAllowlist = allowlist.some((entry) => String(entry || '').trim() !== '');
77
- if (hasAllowlist) return matchesHostAllowlist(hostname, allowlist);
78
- return p.allowPrivateNetwork === true;
77
+ // 规则 1:总闸未开一律拒绝,白名单不单独生效
78
+ if (p.allowPrivateNetwork !== true) return { allowed: false, reason: 'gateClosed' };
79
+ const allowlist = (Array.isArray(p.allowedPrivateHosts) ? p.allowedPrivateHosts : [])
80
+ .filter((entry) => String(entry || '').trim() !== '');
81
+ // 规则 2:开关开着但没配任何条目 → 仍拒绝,避免存在"整个内网全开"的状态
82
+ if (allowlist.length === 0) return { allowed: false, reason: 'noAllowlistedHost' };
83
+ // 规则 3:仅放行命中条目
84
+ if (matchesHostAllowlist(hostname, allowlist)) return { allowed: true, reason: 'allowlisted' };
85
+ return { allowed: false, reason: 'hostNotAllowlisted' };
86
+ }
87
+
88
+ /**
89
+ * 私网目标是否放行(decidePrivateAccess 的布尔视图,保持既有调用方与用例形状)
90
+ */
91
+ export function isPrivateTargetAllowed(hostname, policy) {
92
+ return decidePrivateAccess(hostname, policy).allowed;
79
93
  }
80
94
 
81
95
  /**
@@ -104,8 +118,9 @@ export async function assertUrlAllowed(urlStr, policy) {
104
118
  ? [hostname]
105
119
  : (await lookup(hostname, { all: true })).map((r) => r.address);
106
120
  if (!ips.some(isPrivateAddress)) return; // 公网目标直接放行
107
- if (!isPrivateTargetAllowed(hostname, policy)) {
108
- throw new RagError('urlPrivateBlocked', { host: hostname });
121
+ const decision = decidePrivateAccess(hostname, policy);
122
+ if (!decision.allowed) {
123
+ throw new RagError('urlPrivateBlocked', { host: hostname, reason: decision.reason });
109
124
  }
110
125
  }
111
126