@wenbin_wb/dsh-bridge 2.10.2 → 2.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bridge-rpc.js CHANGED
@@ -157,8 +157,8 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
157
157
  if (adminErr) return adminErr;
158
158
 
159
159
  // 未提供的字段保持 undefined 透传:服务端视为"保留现值"
160
- const { serverUrl, accessToken } = payload;
161
- await saveCustomTunnelConfig(serverUrl, accessToken);
160
+ const { serverUrl, accessToken, sseStreaming } = payload;
161
+ await saveCustomTunnelConfig(serverUrl, accessToken, sseStreaming);
162
162
  const status = await service.getStatus();
163
163
  return ok(status);
164
164
  }
@@ -182,8 +182,9 @@ export class FeishuConversationNode extends ConversationBridge {
182
182
  const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
183
183
  const pending = this.pending.get(approvalId)
184
184
  if (pending) {
185
- // 仅审批发起者可决议(与 /yes 的 senderId 校验同一防线)
186
- if (pending.peerId && operatorId && pending.peerId !== operatorId) {
185
+ // 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
186
+ // 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
187
+ if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
187
188
  this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
188
189
  return
189
190
  }
@@ -390,8 +391,13 @@ export class FeishuConversationNode extends ConversationBridge {
390
391
  }
391
392
  req.signal?.addEventListener('abort', onSignalAbort, { once: true })
392
393
 
393
- // peerId 记录发起者:/yes 与卡片按钮都校验决议者身份
394
- this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
394
+ // peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
395
+ // 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
396
+ this.registerApproval(number, {
397
+ number, request: req, resolve: resolveIm, timer,
398
+ peerId: initiator,
399
+ isGroup: Boolean(peer.isGroup),
400
+ })
395
401
 
396
402
  let outcome
397
403
  try {
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { TelegramService } from './telegram/index.js';
26
26
  import { AuthManager } from './auth/manager.js';
27
27
  import { renderLoginPage } from './auth/login-template.js';
28
28
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
+ import { stripSessionProjections } from './session-strip.js';
29
30
  import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
30
31
 
31
32
  const name = 'dsh-bridge';
@@ -183,6 +184,7 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
183
184
  <link rel="manifest" href="/manifest.webmanifest">
184
185
  <link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
185
186
  <link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
187
+ <style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
186
188
  <script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
187
189
  ${BROWSER_ABORT_SIGNAL_POLYFILL}`;
188
190
  const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
@@ -245,6 +247,36 @@ function loopbackHeaders(headers, targetPort) {
245
247
  return out;
246
248
  }
247
249
 
250
+ // hop-by-hop 响应头黑名单:这些头描述的是"当前一跳"的传输语义,反向代理
251
+ // 透传会干扰客户端对响应体的解析(transfer-encoding/connection 并存、
252
+ // keep-alive 连接复用错乱等)。Node http 层会自动生成正确的传输头,故统一剥离。
253
+ const HOP_BY_HOP_HEADERS = new Set([
254
+ 'connection',
255
+ 'keep-alive',
256
+ 'proxy-authenticate',
257
+ 'proxy-authorization',
258
+ 'te',
259
+ 'trailer',
260
+ 'transfer-encoding',
261
+ 'upgrade',
262
+ ]);
263
+
264
+ /**
265
+ * 净化上游响应头:剔除 hop-by-hop 头,返回可安全 writeHead 的干净头对象。
266
+ * 保留原始键大小写(Node writeHead 会归一化处理)。
267
+ * @param {object} headers 上游响应头(IncomingMessage.headers 形式)
268
+ * @param {Set<string>} [extra] 额外需剔除的头(小写)
269
+ */
270
+ function sanitizeProxyHeaders(headers, extra) {
271
+ const out = {};
272
+ for (const [k, v] of Object.entries(headers)) {
273
+ if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
274
+ if (extra && extra.has(k.toLowerCase())) continue;
275
+ out[k] = v;
276
+ }
277
+ return out;
278
+ }
279
+
248
280
  /**
249
281
  * HTTP + WebSocket 代理服务器(带安全认证守门)
250
282
  * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
@@ -412,6 +444,10 @@ class ProxyServer {
412
444
  const isLocked = this.authManager?.isIpBlocked(clientIp);
413
445
  const html = renderLoginPage({
414
446
  hasPassword: this.authManager?.hasPassword,
447
+ // 管理员是否从未设置过任何密码(含独立管理密码):登录页需如实提示,
448
+ // 避免出现"输入任意密码都能进"的假门禁
449
+ noPasswordConfigured: !this.authManager?.hasPassword && !this.authManager?.hasAdminPassword,
450
+ mode: this.authManager?.mode,
415
451
  locked: isLocked,
416
452
  error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
417
453
  });
@@ -431,27 +467,51 @@ class ProxyServer {
431
467
  { host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
432
468
  (proxyRes) => {
433
469
  const contentType = String(proxyRes.headers['content-type'] ?? '');
434
- // 未压缩的 HTML 文档注入 polyfill
435
- if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
470
+ // 会话投影剥离(session.list / session.history):这两个端点响应体可能达
471
+ // 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
472
+ // 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
473
+ const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
474
+ // session 投影剥离须覆盖压缩(gzip)响应:v2.10.4 起 stripSessionProjections 支持
475
+ // gunzip 解压,因此压缩的 session 响应也应进入缓冲分支进行剥离(此前 !isCompressed
476
+ // 门控会跳过压缩响应,导致 DSH 启用 gzip 后局域网/CF/外部隧道入口剥离失效——只有
477
+ // 自建隧道入口正确剥离)。HTML 注入仍只在未压缩时进行(注入逻辑处理明文)。
478
+ const sessionProjectionBuffered = isSessionProjection && proxyRes.statusCode === 200;
479
+ const shouldBuffer = (contentType.includes('text/html') && !isCompressed(proxyRes.headers))
480
+ || sessionProjectionBuffered;
481
+ if (shouldBuffer) {
436
482
  const chunks = [];
437
483
  proxyRes.on('data', (c) => chunks.push(c));
438
484
  proxyRes.on('end', () => {
439
- let html = Buffer.concat(chunks).toString('utf8');
440
- if (!html.includes(INJECT_MARK)) {
441
- html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
442
- }
443
- const out = Buffer.from(html, 'utf8');
485
+ let out = Buffer.concat(chunks);
444
486
  const outHeaders = { ...proxyRes.headers };
487
+ if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
488
+ let html = out.toString('utf8');
489
+ if (!html.includes(INJECT_MARK)) {
490
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
491
+ }
492
+ out = Buffer.from(html, 'utf8');
493
+ } else if (isSessionProjection) {
494
+ const { body, stripped } = stripSessionProjections(pathname, out, proxyRes.headers['content-encoding']);
495
+ if (stripped) {
496
+ out = body;
497
+ // 已修改 body,清除原始压缩编码标记
498
+ delete outHeaders['content-encoding'];
499
+ }
500
+ }
501
+ // 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
502
+ // transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
445
503
  delete outHeaders['content-length'];
446
504
  delete outHeaders['transfer-encoding'];
447
505
  outHeaders['content-length'] = String(out.length);
448
- res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
506
+ res.writeHead(proxyRes.statusCode ?? 200, sanitizeProxyHeaders(outHeaders));
449
507
  res.end(out);
450
508
  });
451
509
  proxyRes.on('error', () => res.destroy());
452
510
  return;
453
511
  }
454
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
512
+ // 直通分支同样剔除 hop-by-hop 头(transfer-encoding/connection 等),
513
+ // 防止透传给客户端导致解析错误;Node 会按 writeHead+pipe 自动生成正确的传输头
514
+ res.writeHead(proxyRes.statusCode ?? 502, sanitizeProxyHeaders(proxyRes.headers));
455
515
  proxyRes.pipe(res);
456
516
  res.on('close', () => proxyRes.destroy());
457
517
  proxyRes.on('error', () => res.destroy());
@@ -481,8 +541,15 @@ class ProxyServer {
481
541
  });
482
542
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
483
543
  socket.write('HTTP/1.1 101 Switching Protocols\r\n');
544
+ // WS 升级成功:手写原始 socket 响应,只放行必要的 101 头(白名单),
545
+ // 避免上游注入任意头(如 set-cookie / 自定义敏感头)透传给浏览器
546
+ const WS_101_ALLOW = new Set([
547
+ 'connection', 'upgrade', 'sec-websocket-accept',
548
+ 'sec-websocket-extensions', 'sec-websocket-protocol',
549
+ ]);
484
550
  const raw = [];
485
551
  for (const [k, v] of Object.entries(proxyRes.headers)) {
552
+ if (!WS_101_ALLOW.has(k.toLowerCase())) continue;
486
553
  raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
487
554
  }
488
555
  socket.write(`${raw.join('\r\n')}\r\n\r\n`);
@@ -500,7 +567,8 @@ class ProxyServer {
500
567
  if (proxyRes.statusCode === 101) return;
501
568
  try {
502
569
  const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
503
- for (const [k, v] of Object.entries(proxyRes.headers)) {
570
+ // 101(如后端拒绝升级):剔除 hop-by-hop 头后再回写
571
+ for (const [k, v] of Object.entries(sanitizeProxyHeaders(proxyRes.headers))) {
504
572
  raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
505
573
  }
506
574
  socket.end(raw.join('\r\n') + '\r\n\r\n');
@@ -574,6 +642,10 @@ class BridgeService {
574
642
  // DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
575
643
  this._dshVersion = null;
576
644
  this._dshVersionLoaded = false;
645
+ // 版本检查结果缓存(10 分钟 TTL):客户端轮询/多处面板重复调用时不反复打 npm registry
646
+ this._versionCheckCache = null;
647
+ this._versionCheckCachedAt = 0;
648
+ this._versionCheckTtlMs = 10 * 60 * 1000;
577
649
  }
578
650
 
579
651
  /**
@@ -583,7 +655,9 @@ class BridgeService {
583
655
  */
584
656
  async getDshVersion() {
585
657
  if (this._dshVersionLoaded) return this._dshVersion;
586
- this._dshVersionLoaded = true; // 只尝试一次,失败也不重试(避免每次 getStatus 都 spawn)
658
+ // 上次探测失败后的退避期未到:不 spawn,直接返回 null(面板仍可正常加载)
659
+ if (this._dshVersionRetryAt && Date.now() < this._dshVersionRetryAt) return this._dshVersion;
660
+ this._dshVersionLoaded = true; // 尝试期间置位;成功保持,失败在 catch 里复位并设退避
587
661
 
588
662
  const isWin = process.platform === 'win32';
589
663
  const nodeDir = dirname(process.execPath);
@@ -624,6 +698,10 @@ class BridgeService {
624
698
  } catch (e) {
625
699
  this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
626
700
  this._dshVersion = null;
701
+ // 失败:复位已加载标记并设 10 分钟退避期。退避期内 getStatus 不再 spawn,
702
+ // 到期后允许重试一次(用户修复 PATH 后无需重启即可恢复版本显示)。
703
+ this._dshVersionLoaded = false;
704
+ this._dshVersionRetryAt = Date.now() + 10 * 60 * 1000;
627
705
  }
628
706
  return this._dshVersion;
629
707
  }
@@ -644,9 +722,14 @@ class BridgeService {
644
722
  targetPort: this.dshPort,
645
723
  authManager: this.authManager,
646
724
  logger: this.logger,
647
- // loopback-token 允许跨域的面板来源:回环、当前局域网 IP、隧道公网地址
725
+ // loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
726
+ // 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
727
+ // 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
648
728
  allowedOrigins: () => {
649
- const origins = [`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`];
729
+ const origins = [
730
+ `http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`,
731
+ `http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
732
+ ];
650
733
  try {
651
734
  for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
652
735
  if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
@@ -732,6 +815,7 @@ class BridgeService {
732
815
  configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
733
816
  serverUrl: this.customTunnelConfig?.serverUrl ?? '',
734
817
  running: !!this.customTunnel?.connected,
818
+ sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
735
819
  url: customUrl,
736
820
  rawUrl: baseCustomUrl,
737
821
  qr: customUrl
@@ -827,6 +911,7 @@ class BridgeService {
827
911
  accessToken,
828
912
  localPort: this.proxyPort,
829
913
  internalTunnelSecret: this.authManager?.internalTunnelSecret,
914
+ sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
830
915
  onStateChange: (state) => {
831
916
  this.customTunnelState = state;
832
917
  },
@@ -915,6 +1000,10 @@ class BridgeService {
915
1000
 
916
1001
  // 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
917
1002
  async checkVersion() {
1003
+ // TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
1004
+ if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
1005
+ return this._versionCheckCache;
1006
+ }
918
1007
  const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
919
1008
  const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
920
1009
  if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
@@ -939,13 +1028,17 @@ class BridgeService {
939
1028
  try {
940
1029
  const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
941
1030
  .catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
942
- return {
1031
+ const result = {
943
1032
  current: VERSION,
944
1033
  latest: latestData?.version ?? null,
945
1034
  releaseNotes: latestData?.releaseNotes ?? null,
946
1035
  dshVersion: await this.getDshVersion(),
947
1036
  };
1037
+ this._versionCheckCache = result;
1038
+ this._versionCheckCachedAt = Date.now();
1039
+ return result;
948
1040
  } catch (e) {
1041
+ // 失败不缓存(让下次调用可重试),但记录便于诊断
949
1042
  return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
950
1043
  }
951
1044
  }
@@ -1775,13 +1868,14 @@ function apply(ctx, config = {}) {
1775
1868
  telegram,
1776
1869
  platformManager,
1777
1870
  logger,
1778
- saveCustomTunnelConfig: async (serverUrl, accessToken) => {
1871
+ saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
1779
1872
  const stored = await updateConfig((current) => {
1780
1873
  const prev = service.customTunnelConfig ?? {};
1781
1874
  const next = { ...prev };
1782
1875
  // 与 saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
1783
1876
  if (serverUrl !== undefined) next.serverUrl = String(serverUrl).trim();
1784
1877
  if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
1878
+ if (sseStreaming !== undefined) next.sseStreaming = Boolean(sseStreaming);
1785
1879
  current.customTunnel = next;
1786
1880
  return current;
1787
1881
  });
@@ -21,7 +21,7 @@ import { randomUUID } from 'node:crypto'
21
21
  import { stat } from 'node:fs/promises'
22
22
  import { normalize } from 'node:path'
23
23
  import { resolveFilePath } from './message-split.js'
24
- import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText } from './message-split.js'
24
+ import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText, isPathAllowedForSend } from './message-split.js'
25
25
  import { routeCommand } from './commands.js'
26
26
  import { listSessions, listWorkspaces, validateWorkspacePath, renderSessions, sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId, sessionLabel } from './session-catalog.js'
27
27
 
@@ -665,9 +665,14 @@ export class ConversationBridge {
665
665
  const uniqueFilesToSend = []
666
666
  for (const f of rawFiles) {
667
667
  const resolved = resolveFilePath(f, cwd)
668
- if (resolved && !uniqueFilesToSend.includes(resolved)) {
669
- uniqueFilesToSend.push(resolved)
668
+ if (!resolved || uniqueFilesToSend.includes(resolved)) continue
669
+ // 发送白名单:仅允许会话 cwd(及其子目录)内、且不命中敏感路径的文件,
670
+ // 防止模型被提示注入后借 [SEND_FILE] 外发 .ssh/.credentials/.env 等任意本地文件。
671
+ if (!isPathAllowedForSend(resolved, cwd)) {
672
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] blocked SEND_FILE outside allowed workspace: ${resolved}`)
673
+ continue
670
674
  }
675
+ uniqueFilesToSend.push(resolved)
671
676
  }
672
677
  for (const resolved of uniqueFilesToSend) {
673
678
  try {
@@ -2,7 +2,7 @@
2
2
  // 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
3
3
  // [SEND_FILE: ...] 显式指令提取与路径解析。
4
4
  import { statSync } from 'node:fs'
5
- import { isAbsolute, normalize, resolve } from 'node:path'
5
+ import { isAbsolute, normalize, relative, resolve } from 'node:path'
6
6
 
7
7
  const FENCE_RE = /^```([^\n`]*)\s*$/
8
8
 
@@ -149,6 +149,44 @@ export function resolveFilePath(rawPath, cwd = process.cwd()) {
149
149
  return null
150
150
  }
151
151
 
152
+ /**
153
+ * 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
154
+ * 安全约束(防止模型被诱导后外发任意本地文件):
155
+ * 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
156
+ * 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
157
+ * @param {string} resolvedPath 已解析的绝对路径
158
+ * @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
159
+ * @returns {boolean}
160
+ */
161
+ export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
162
+ if (typeof resolvedPath !== 'string' || !resolvedPath) return false
163
+ const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
164
+ if (roots.length === 0) return false
165
+
166
+ const normalized = resolve(resolvedPath)
167
+ const pathParts = normalized.split(/[\\/]/).filter(Boolean)
168
+ const SENSITIVE_PARTS = new Set([
169
+ '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
170
+ '.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
171
+ '.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
172
+ 'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
173
+ ])
174
+ for (const part of pathParts) {
175
+ if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
176
+ }
177
+
178
+ for (const root of roots) {
179
+ if (typeof root !== 'string' || !root) continue
180
+ const normRoot = resolve(root)
181
+ if (normalized === normRoot) return true
182
+ // 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
183
+ // (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
184
+ const rel = relative(normRoot, normalized)
185
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
186
+ }
187
+ return false
188
+ }
189
+
152
190
  /**
153
191
  * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
154
192
  * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。