mingdao-harness 0.6.6 → 0.6.7

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/src/cost-guard.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import fs from 'node:fs';
8
8
  import { DEFAULT_MODEL } from './models.js';
9
- import { listCacheStats, cacheStatsFile } from './cachestats.js';
9
+ import { listCacheStatsStrict, cacheStatsFile } from './cachestats.js';
10
10
  import { beijingDayStart, hasPricing } from './pricing.js';
11
11
  import { loadConfig } from './config.js';
12
12
 
@@ -17,40 +17,68 @@ export function costGuardConfig() {
17
17
  // 今日(北京时间 0 点起)累计实际费用。
18
18
  // 质检 M10:按 cache-stats 文件 mtime 缓存统计结果——文件未变直接复用,
19
19
  // 避免 Agent 每步对整文件(可能数千行)重复解析与累加。
20
- let todayCache = { mtime: -1, size: -1, start: 0, sum: 0 };
20
+ let todayCache = { mtime: -1, size: -1, start: 0, sum: 0, unknown: 0 };
21
21
  // v0.4.1 P2 修复(TDZ):todayCostWarned 提到 todayCost 使用点之前声明——
22
22
  // 此前在第 44 行才 let 声明、第 36 行已使用(模块加载顺序碰巧掩盖了风险,循环依赖场景会抛 ReferenceError)。
23
23
  let todayCostWarned = false;
24
+ let unknownUsageWarned = false; // 用量未知的告警也只需一次(否则每步刷屏)
25
+ /**
26
+ * 今日累计费用(北京时间自然日)。
27
+ *
28
+ * v0.6.7(报告一 H-3):读失败路径改为**严格判定**——此前走 `listCacheStats()`,它内部 `catch → []`,
29
+ * 于是"statSync 能过、readFileSync 失败"(Windows 杀软锁文件、权限竞态、盘符瞬断)被当成
30
+ * **今日 ¥0**:护栏据此判定未超支并放行,且没有告警。护栏要的是"能不能读到",不是"读到几条"。
31
+ * @returns {number|null} null = 读不到(护栏降级为"无法判断",绝不静默当 0)
32
+ */
24
33
  export function todayCost() {
25
34
  const start = beijingDayStart().getTime();
35
+ let st;
26
36
  try {
27
- const file = cacheStatsFile();
28
- const st = fs.statSync(file);
29
- if (st.mtimeMs === todayCache.mtime && st.size === todayCache.size && todayCache.start === start) {
30
- return todayCache.sum;
31
- }
32
- let sum = 0;
33
- for (const e of listCacheStats(100000)) {
34
- if (e.at >= start) sum += Number(e.cost) || 0;
35
- }
36
- todayCache = { mtime: st.mtimeMs, size: st.size, start, sum };
37
- return sum;
37
+ st = fs.statSync(cacheStatsFile());
38
38
  } catch (/** @type {any} */ err) {
39
- // v0.4.7 修复:区分「文件还不存在」与「真的读不了」。
40
- // 全新安装的第一个回合必然没有 cache-stats.jsonl —— 那是「今天还没花钱」(0),
41
- // 不是「统计损坏」。此前一律返回 null 并打印「修复 cache-stats.jsonl」的告警:
42
- // 既误导用户(让人去修一个不存在的文件),又让首回合护栏静默失效。
39
+ // 区分「文件还不存在」与「真的读不了」:全新安装的第一个回合必然没有该文件——
40
+ // 那是「今天还没花钱」(0),不是「统计损坏」。
43
41
  if (err?.code === 'ENOENT') {
44
- todayCache = { mtime: 0, size: 0, start, sum: 0 };
42
+ todayCache = { mtime: 0, size: 0, start, sum: 0, unknown: 0 };
45
43
  return 0;
46
44
  }
47
- // 真正的读失败:返回 null(护栏无法判断)并一次性告警——绝不静默当 0
48
- if (!todayCostWarned) {
49
- todayCostWarned = true;
50
- console.warn('[MingDao] 费用统计读取失败:今日费用护栏暂时无法判断(修复 cache-stats.jsonl 或重启后恢复)。');
45
+ warnTodayCostDegraded(String(err?.message ?? err));
46
+ return null;
47
+ }
48
+ if (st.mtimeMs === todayCache.mtime && st.size === todayCache.size && todayCache.start === start) {
49
+ return todayCache.sum;
50
+ }
51
+ const r = listCacheStatsStrict(100000);
52
+ if (!r.ok) {
53
+ if (r.code === 'ENOENT') {
54
+ todayCache = { mtime: 0, size: 0, start, sum: 0, unknown: 0 };
55
+ return 0;
51
56
  }
57
+ warnTodayCostDegraded(r.error);
52
58
  return null;
53
59
  }
60
+ let sum = 0;
61
+ let unknown = 0;
62
+ for (const e of r.entries) {
63
+ if (!(e.at >= start)) continue;
64
+ if (e.usageUnknown === true) unknown += 1; // 这次消费金额未知:金额不计,但事实要看得见
65
+ sum += Number(e.cost) || 0;
66
+ }
67
+ todayCache = { mtime: st.mtimeMs, size: st.size, start, sum, unknown };
68
+ return sum;
69
+ }
70
+
71
+ /** 读失败只告警一次(避免每步刷屏),但每次都让调用方拿到 null。 */
72
+ function warnTodayCostDegraded(/** @type {string} */ reason) {
73
+ if (todayCostWarned) return;
74
+ todayCostWarned = true;
75
+ console.warn(`[MingDao] 费用统计读取失败:今日费用护栏暂时无法判断(${reason})——修复 cache-stats.jsonl 的权限/占用后自动恢复。`);
76
+ }
77
+
78
+ /** 今日「用量未知」的调用次数(护栏据此给出可见信号:金额算不出,但少计这件事必须说出来)。 */
79
+ export function todayCostUnknownCount() {
80
+ todayCost(); // 触发一次统计(含缓存失效判定)
81
+ return Number(todayCache.unknown) || 0;
54
82
  }
55
83
 
56
84
  /**
@@ -77,6 +105,9 @@ export function costGuardStatus(modelName) {
77
105
  downgradeModel: String(g.downgradeModel || DEFAULT_MODEL),
78
106
  degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
79
107
  noPricing, // 无价格数据:费用护栏无法累计,需显式告警
108
+ // v0.6.7(H-4):今日有几次调用的用量未知(服务端没回 usage)——金额算不出,
109
+ // 但"少计"这件事必须能被告警出来,否则"没花钱"与"没记账"无法区分。
110
+ unknownUsageCount: todayCostUnknownCount(),
80
111
  overWarn: cost !== null && !noPricing && limit > 0 && cost >= warnAt,
81
112
  overLimit: cost !== null && !noPricing && limit > 0 && cost >= limit,
82
113
  };
@@ -117,5 +148,15 @@ export function checkCostGuard(modelName) {
117
148
  message: `⚠ 费用护栏:今日已用 ¥${(st.cost ?? 0).toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
118
149
  };
119
150
  }
151
+ // v0.6.7(报告二 S-2):任何会让 todayCost 少计的路径都必须产生**可见信号**——
152
+ // 但只能放在**所有硬判定之后**:block/降级/告警线都比它优先(否则"有未知消费"会把
153
+ // 真正的超限拦截顶掉。这是本批实证的一处顺序缺陷:batch 的护栏断言当场抓到)。
154
+ if (st.unknownUsageCount > 0 && !unknownUsageWarned) {
155
+ unknownUsageWarned = true;
156
+ return {
157
+ blocked: false,
158
+ message: `⚠ 费用护栏:今日有 ${st.unknownUsageCount} 次调用的**用量未知**(服务端未返回 usage)——金额无法估算,护栏可能少计这部分消费。若使用自建网关,请让其透传 usage 字段。`,
159
+ };
160
+ }
120
161
  return null;
121
162
  }
@@ -73,15 +73,24 @@ export function getStoredKey(providerName) {
73
73
 
74
74
  /** @param {any} providerName @param {any} key */
75
75
  export function setStoredKey(providerName, key) {
76
- const creds = loadCredentials();
76
+ // v0.6.7(报告一 M-1;H-8 只修了 key 命令那一层):**严格读必须内置在写函数里**。
77
+ // 写路径是「读 → 改一个键 → 全量重写」,宽松读(读不出来当空对象)会让一次 `init` 向导
78
+ // 或 WebUI 设 Key 就把其余全部凭据静默清空——修在调用点等于要求每个调用点都自觉。
79
+ // 现在:读不出来就**拒绝写**,并把原因返回给调用方(谁也不许静默"成功")。
80
+ const r = readCredentialsStrict();
81
+ if (!r.ok) {
82
+ return { ok: false, error: `凭证库读不出来,已拒绝写入以免清空其余凭据:${r.error}(原文件保持原样,请人工检查 ${credentialsPath()})` };
83
+ }
84
+ const creds = r.data;
77
85
  if (key) creds[providerName] = String(key);
78
86
  else delete creds[providerName];
79
87
  saveCredentials(creds);
88
+ return { ok: true, error: null };
80
89
  }
81
90
 
82
91
  /** @param {any} providerName */
83
92
  export function removeStoredKey(providerName) {
84
- setStoredKey(providerName, null);
93
+ return setStoredKey(providerName, null);
85
94
  }
86
95
 
87
96
  // 脱敏展示:只显示首 6 位与末 4 位,永不输出完整 Key。
package/src/ledger.js CHANGED
@@ -65,7 +65,12 @@ export function redactDeep(value, depth = 0, fn = redactSecrets) {
65
65
  if (Array.isArray(value)) return value.slice(0, 100).map((v) => redactDeep(v, depth + 1, fn));
66
66
  /** @type {any} */
67
67
  const out = {};
68
- for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, depth + 1, fn);
68
+ // v0.6.7(报告二 P3-7):对象此前**全量遍历、键数无上限**——工具结果里带一个 10 万键的对象,
69
+ // 每次写账本都要整份递归(数组早有 slice(0,100)、字符串有 MAX_TEXT、深度有 4 层上限,只有对象漏了)。
70
+ // 与数组同例:保留前 100 个键,并**显式标记**被截断(静默丢键会让账本看起来"就是这些字段")。
71
+ const entries = Object.entries(value);
72
+ for (const [k, v] of entries.slice(0, 100)) out[k] = redactDeep(v, depth + 1, fn);
73
+ if (entries.length > 100) out['…'] = `[更多键已截断:共 ${entries.length} 个键]`;
69
74
  return out;
70
75
  }
71
76
 
@@ -254,6 +259,9 @@ export function createLedger(runId, { enabled = true, maxRuns = DEFAULT_MAX_RUNS
254
259
  pricing: f.pricing ?? null,
255
260
  yuan: f.yuan ?? null,
256
261
  priced: f.priced === true,
262
+ // v0.6.7(报告一 H-4):**用量未知**必须是一个显式事实,而不是"priced:false"里的一条注释——
263
+ // 后端不回 usage 时 priced 与"无价模型"都是 false,事后稽核却要能区分这两件事。
264
+ ...(f.usageUnknown === true ? { usageUnknown: true } : {}),
257
265
  });
258
266
  },
259
267
  netEgress(/** @type {any} */ f = {}) {
@@ -275,6 +283,8 @@ export function createLedger(runId, { enabled = true, maxRuns = DEFAULT_MAX_RUNS
275
283
  capHit: f.capHit === true,
276
284
  truncated: f.truncated === true,
277
285
  aborted: f.aborted === true,
286
+ // v0.6.7(报告二 P3-2):上游提前关流此前只"置位"不落账(检测到了、传递断了)
287
+ ...(f.upstreamTruncated === true ? { upstreamTruncated: true } : {}),
278
288
  });
279
289
  // 只有 run.end **真的落盘了**才封条:局部失败时封一条残缺账本会让校验谎报完整
280
290
  if (line) seal(line);
package/src/model-caps.js CHANGED
@@ -6,7 +6,7 @@ import { modelPreset } from './models.js';
6
6
  // 审计 P3-3(v0.4.2):本地判定复用 fetch.js 的 isPrivateHost(IPv4 私网/回环/CGNAT/多播 + IPv6
7
7
  // fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
8
8
  // (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
9
- import { isPrivateHost } from './tools/fetch.js';
9
+ import { isPrivateHost } from './ssrf-guard.js';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
 
package/src/net-guard.js CHANGED
@@ -110,6 +110,78 @@ export function decideEgress(/** @type {any} */ url) {
110
110
  return d;
111
111
  }
112
112
 
113
+ /**
114
+ * 不经 `globalThis.fetch` 的外联路径统一入口(v0.6.7;报告 3 的 M1)。
115
+ *
116
+ * 背景:闸门此前**只**包 `globalThis.fetch`,而 `safe-fetch.js` 走 `node:http(s).request`
117
+ * (它必须这样——只有 http 模块能把"已校验的 IP"钉给连接层,防 DNS rebinding)。
118
+ * 于是「skill 下载 / registry 拉取 / 模型发现」三条自动出口既不受 block 拦截、也不进出网账本:
119
+ * 用户配了 `config.net` 想自证"数据不出门",实际漏的恰恰是这几条自动路径——
120
+ * 对一个把"出网自证"当卖点的项目,**失真的安全叙事比明确不拦更危险**。
121
+ *
122
+ * 语义与 globalThis.fetch 那条完全一致(同一份 activePolicy、同一个 recordEgress):
123
+ * · 未安装 → 放行、不记账;
124
+ * · block 且不在白名单 → `{ blocked: true, message }`,调用方必须拒绝本次连接;
125
+ * · warn 且不在白名单 → 放行 + 记账 + **只告警一次**(避免刷屏)。
126
+ * @param {any} url
127
+ * @returns {{ blocked: boolean, message: string, decision: any }}
128
+ */
129
+ export function guardEgress(/** @type {any} */ url) {
130
+ const d = decideEgress(url);
131
+ if (d.allowed) return { blocked: false, message: '', decision: d };
132
+ if (activePolicy?.mode === 'block') {
133
+ return {
134
+ blocked: true,
135
+ message:
136
+ `出网被拦截:${d.host}${d.port ? ':' + d.port : ''} 不在 config.net.allow 白名单内(mode=block)。` +
137
+ `若这是必要的外部依赖,请把该主机显式加入白名单;若只是想让数据不出门,请保持拦截并改用内网端点。`,
138
+ decision: d,
139
+ };
140
+ }
141
+ if (!warnedOnce) {
142
+ warnedOnce = true;
143
+ try {
144
+ process.stderr.write(`[MingDao] ⚠ 出网告警:${d.host} 不在白名单内(mode=warn,已放行并记账)。运行 mingdao net report 查看明细。\n`);
145
+ } catch {}
146
+ }
147
+ return { blocked: false, message: '', decision: d };
148
+ }
149
+
150
+ /** 跨 origin 跳转时**允许保留**的请求头(反向白名单)。
151
+ *
152
+ * v0.6.7(报告 1 M-4):此前是**黑名单**(只删 authorization / cookie / proxy-authorization),
153
+ * 于是 `x-api-key` / `api-key` / `x-auth-token` / `x-amz-security-token` 这类自定义凭据头
154
+ * 会原样带给重定向目标——而"自定义头里放密钥"恰恰是网关类服务的常见做法。
155
+ * 反向白名单的方向与安全一致:**没被点名的头一律不跨源**(新增自定义头默认安全)。
156
+ * 白名单只放"不带身份、且对重定向后的请求仍需成立"的头。 */
157
+ const CROSS_ORIGIN_KEEP_HEADERS = new Set([
158
+ 'accept',
159
+ 'accept-encoding',
160
+ 'accept-language',
161
+ 'cache-control',
162
+ 'content-length',
163
+ 'content-type',
164
+ 'if-match',
165
+ 'if-modified-since',
166
+ 'if-none-match',
167
+ 'if-range',
168
+ 'if-unmodified-since',
169
+ 'range',
170
+ 'user-agent',
171
+ ]);
172
+
173
+ /** 按反向白名单裁掉不该跨源的头;返回新的 Headers(输入未被修改)。
174
+ * @param {any} headers */
175
+ export function stripHeadersForCrossOrigin(headers) {
176
+ const src = new Headers(headers || {});
177
+ /** @type {[string,string][]} */
178
+ const keep = [];
179
+ src.forEach((v, k) => {
180
+ if (CROSS_ORIGIN_KEEP_HEADERS.has(String(k).toLowerCase())) keep.push([k, v]);
181
+ });
182
+ return new Headers(keep);
183
+ }
184
+
113
185
  /**
114
186
  * 安装出网闸门(幂等)。未配置 config.net → 不安装,返回 false。
115
187
  * @param {any} rawNet cfg.net
@@ -129,14 +201,8 @@ export function installEgressGate(/** @type {any} */ rawNet) {
129
201
  );
130
202
  globalThis.fetch = async (/** @type {any} */ input, /** @type {any} */ init) => {
131
203
  const url = typeof input === 'string' ? input : input && typeof input === 'object' && 'url' in input ? input.url : String(input);
132
- const d = decideEgress(url);
133
- if (!d.allowed && activePolicy?.mode === 'block') throw blockedError(d);
134
- if (!d.allowed && activePolicy?.mode === 'warn' && !warnedOnce) {
135
- warnedOnce = true;
136
- try {
137
- process.stderr.write(`[MingDao] ⚠ 出网告警:${d.host} 不在白名单内(mode=warn,已放行并记账)。运行 mingdao net report 查看明细。\n`);
138
- } catch {}
139
- }
204
+ const g = guardEgress(url); // 与 safe-fetch 共用同一个入口(block 抛错 / warn 一次性告警 + 记账)
205
+ if (g.blocked) throw blockedError(g.decision);
140
206
  // 重定向必须**逐跳**判定(v0.6.0 自查发现的绕过):
141
207
  // 调用方不指定 redirect 时 undici 默认自己跟随 3xx,闸门只看得到**首个** URL——
142
208
  // 于是「允许 api.deepseek.com」会被利用成:该主机返回 302 指向任意地址,内核照样跟过去,
@@ -155,21 +221,38 @@ export function installEgressGate(/** @type {any} */ rawNet) {
155
221
  const isRequestInput = Boolean(input) && typeof input === 'object' && typeof input.url === 'string';
156
222
  /** @type {any} */
157
223
  let current = url; // 字符串 URL:供 new URL(loc, current) 使用(不能是 Request 对象)
224
+ // v0.6.7(报告 2 的 P2-3):**Request + init 并存时逐字段合并**。
225
+ // 此前只在「只传 Request、没有 init」时才继承其语义;而 `fetch(new Request(u,{method:'POST',body}), {signal})`
226
+ // 这种「加个 AbortSignal」的常见写法会走 `{...init, redirect:'manual'}` 分支,
227
+ // 于是 method/body/headers 全丢 → **静默降级成空 GET**(行为级实测:服务端收到 `GET body=""`)。
228
+ // 透明代理改变请求语义属于「安全组件引入数据损坏」,必须逐字段按 fetch 规范合并:
229
+ // init 优先,未给的字段回落到 Request。
158
230
  /** @type {any} */
159
- let curInit =
160
- isRequestInput && !init
161
- ? {
162
- // 有 init 时按 fetch 规范由 init 覆盖,这里只在「只传 Request」时继承其语义
163
- method: input.method,
164
- headers: input.headers,
165
- body: input.body,
166
- signal: input.signal,
167
- ...(input.duplex ? { duplex: input.duplex } : {}),
168
- redirect: 'manual',
169
- }
170
- : init
171
- ? { ...init, redirect: 'manual' }
172
- : { redirect: 'manual' };
231
+ let curInit;
232
+ if (isRequestInput) {
233
+ /** @type {any} */
234
+ const merged = {};
235
+ const method = init?.method ?? input.method;
236
+ if (method) merged.method = method;
237
+ if (input.headers || init?.headers) {
238
+ const h = new Headers(input.headers || {});
239
+ if (init?.headers) new Headers(init.headers).forEach((v, k) => h.set(k, v));
240
+ merged.headers = h;
241
+ }
242
+ const body = init?.body ?? input.body;
243
+ if (body !== undefined && body !== null) merged.body = body;
244
+ const signal = init?.signal ?? input.signal;
245
+ if (signal) merged.signal = signal;
246
+ if (input.duplex && !init?.duplex) merged.duplex = input.duplex;
247
+ for (const k of ['mode', 'credentials', 'cache', 'integrity', 'keepalive', 'referrer', 'referrerPolicy']) {
248
+ const v = init?.[k] ?? input[k];
249
+ if (v !== undefined && v !== null) merged[k] = v;
250
+ }
251
+ merged.redirect = 'manual'; // 由本闸门逐跳处理
252
+ curInit = merged;
253
+ } else {
254
+ curInit = init ? { ...init, redirect: 'manual' } : { redirect: 'manual' };
255
+ }
173
256
  for (let hop = 0; ; hop++) {
174
257
  const res = await base(current, curInit);
175
258
  if (![301, 302, 303, 307, 308].includes(res.status)) return res;
@@ -207,12 +290,11 @@ export function installEgressGate(/** @type {any} */ rawNet) {
207
290
  // 手动跟随重定向时 `curInit` 原样复用,而 undici 在 `redirect:'manual'` 下不会替你做
208
291
  // 同源剥离——实测 warn 模式的第二跳 `https://attacker.example/steal` 仍带着第一跳的
209
292
  // `Authorization: Bearer sk-…`。与 mode 无关:warn 放行的是「出网」,不该顺手把凭据送出去。
210
- // 与 fetch 规范对齐:只有同源(scheme+host+port 全同)才保留这些头。
293
+ // v0.6.7(报告 1 M-4):改**反向白名单**(见 stripHeadersForCrossOrigin)——
294
+ // 黑名单只删得掉三个头,`x-api-key` 之类的自定义凭据头会原样泄漏给重定向目标。
211
295
  try {
212
296
  if (new URL(next).origin !== new URL(String(current)).origin && curInit?.headers) {
213
- const h = new Headers(curInit.headers);
214
- for (const k of ['authorization', 'cookie', 'proxy-authorization']) h.delete(k);
215
- curInit = { ...curInit, headers: h };
297
+ curInit = { ...curInit, headers: stripHeadersForCrossOrigin(curInit.headers) };
216
298
  }
217
299
  } catch {}
218
300
  try {
@@ -33,7 +33,12 @@ function summarize(name, args) {
33
33
  */
34
34
  export function splitShellSegments(/** @type {any} */ cmd) {
35
35
  return String(cmd ?? '')
36
- .split(/\s*(?:;|&&|\|\||\||&|\n)\s*/)
36
+ // v0.6.7(报告一 M-2 / 报告二 P3-6):分隔面补全——此前只拆 `; && || | & 换行`,于是
37
+ // `echo $(rm -rf /x)`、`` echo `rm -rf /x` ``、`true \r rm -rf /x`、`a \\\n rm -rf /x`
38
+ // 的**段首**都不是危险命令,deny 规则被直达绕过(deny 是 auto 档唯一防线)。
39
+ // 现在把命令替换(`$(` / 反引号)、子 shell 括号、`\r`、行尾续行反斜杠一并当分隔符。
40
+ // 方向是 fail-closed:多拆只会多匹配 deny 规则(最坏是多问一次确认),不会漏掉危险段。
41
+ .split(/\s*(?:;|&&|\|\||\||&|\r|\n|\$\(|\(|\)|`|\\\r?\n)\s*/)
37
42
  .map((/** @type {string} */ seg) => seg.trim())
38
43
  .filter(Boolean);
39
44
  }
@@ -115,10 +120,31 @@ export function normalizePermission(/** @type {any} */ rawMode) {
115
120
  * @param {any} args
116
121
  * @returns {{decision: 'allow'|'deny'|'ask', reason: string, rule: string|null, denyStrict?: boolean}}
117
122
  */
123
+ // v0.6.7(报告二 P3-6,报告一 M-2 的另一半):**denyStrict 下包装执行形态 fail-closed**。
124
+ //
125
+ // 拆段(splitShellSegments)已经补上命令替换与子 shell 括号,但 `sh -c '…'`、`eval '…'`、
126
+ // `bash -c "…"` 这类**把真命令藏在参数里**的写法无法靠拆段看穿:段首是 `sh`/`eval`,
127
+ // 不匹配任何以真实命令开头的 deny 规则。普通 deny 档可以接受这一点(它是"防手滑",
128
+ // 被绕过还有权限模式兜底);但 `denyStrict: true` 是用户显式要求的**硬拦截**,
129
+ // 语义上不该存在"换个写法就直达"的通道 —— 于是这里对包装形态直接拒绝。
130
+ const SHELL_WRAPPER = /(^|\s|;|&&|\|\||\|)(sh|bash|zsh|dash|ksh|cmd|powershell|pwsh)\s+-c\b|(^|\s|;|&&|\|\||\|)eval\b|(^|\s|;|&&|\|\||\|)command\s+-v\b/i;
131
+
132
+ /**
133
+ * @param {any} rawMode @param {any} name @param {any} args
134
+ */
118
135
  export function evaluatePermission(/** @type {any} */ rawMode, /** @type {any} */ name, /** @type {any} */ args = {}) {
119
136
  const { mode, allow, deny, denyStrict } = normalizePermission(rawMode);
120
137
  const hitDeny = deny.find((/** @type {any} */ r) => ruleMatches(r, name, args, true));
121
138
  if (hitDeny) return { decision: 'deny', reason: 'rule-deny', rule: hitDeny, denyStrict };
139
+ // denyStrict + 包装执行:deny 规则看不穿参数里的命令,只能整体拒绝(给出可操作的指引)
140
+ if (denyStrict && deny.length > 0 && name === 'bash' && SHELL_WRAPPER.test(String(args?.command ?? ''))) {
141
+ return {
142
+ decision: 'deny',
143
+ reason: 'deny-strict-wrapper',
144
+ rule: 'denyStrict:shell-wrapper',
145
+ denyStrict: true,
146
+ };
147
+ }
122
148
  const hitAllow = allow.find((/** @type {any} */ r) => ruleMatches(r, name, args));
123
149
  if (hitAllow) return { decision: 'allow', reason: 'rule-allow', rule: hitAllow };
124
150
  if (mode === 'auto') return { decision: 'allow', reason: 'mode-auto', rule: null };
@@ -155,7 +181,8 @@ export function createPermission(rawMode, io) {
155
181
  io.print(
156
182
  style(
157
183
  `⛔ 已拒绝:${name}${summarize(name, args)} 命中 deny 规则「${v.rule}」` +
158
- `(config.permission.denyStrict=true:deny 为硬拦截,不提供本次放行)。`,
184
+ `(config.permission.denyStrict=true:deny 为硬拦截,不提供本次放行)。` +
185
+ (v.reason === 'deny-strict-wrapper' ? '\n 原因:命令用 sh -c / eval 等包装执行,deny 规则看不穿参数里的真实命令。请直接写命令本身(不要包一层 shell)。' : ''),
159
186
  C.red
160
187
  )
161
188
  );
@@ -198,7 +198,15 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
198
198
  // 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
199
199
  const customFile = customProviderFile(pc.name); // R4:见上方说明(白名单 + 包含性检查)
200
200
  if (customFile && pc.isCustom && !pc.name.includes(':') && fs.existsSync(customFile)) {
201
- const mod = await import(pathToFileURL(customFile).href + `?v=${Date.now()}`);
201
+ // v0.6.7(报告一 M-13):此前是 `?v=${Date.now()}` ——**每次建 provider 都换一个 URL**,
202
+ // 而 ESM 缓存按 URL 计:长驻 WebUI 里模块实例无限累积、顶层代码反复执行。
203
+ // 改成按 **mtime** 作版本键(同文件里 visionProbeCache 已是这个方案):文件没改就复用同一实例,
204
+ // 改了才重新 import。
205
+ let ver = '0';
206
+ try {
207
+ ver = String(Math.round(fs.statSync(customFile).mtimeMs));
208
+ } catch {}
209
+ const mod = await import(pathToFileURL(customFile).href + `?v=${ver}`);
202
210
  if (typeof mod.createProvider !== 'function') {
203
211
  throw new Error(`自定义 Provider 模块 ${customFile} 未导出 createProvider(cfg)。`);
204
212
  }
package/src/redact.js CHANGED
@@ -5,10 +5,16 @@ import os from 'node:os';
5
5
 
6
6
  // 常见密钥前缀(GitHub/OpenAI/AWS/Slack/Google 等);sk- 保留前缀、其余整体掩码
7
7
  // 密钥词(按 `_`/`-` 分段后**整段相等**才算命中,避免 `monkey` 这类子串误伤)
8
- const SECRET_NAME_SEGMENTS = new Set([
8
+ export const SECRET_NAME_SEGMENTS = new Set([
9
9
  'key', 'keys', 'apikey', 'token', 'secret', 'password', 'passwd', 'pwd', 'pass',
10
10
  'credential', 'credentials', 'auth', 'authorization', 'cookie', 'session',
11
11
  ]);
12
+ // v0.6.7(报告一 M-3):子进程 env 过滤(bash/hooks/MCP)与脱敏器必须**同一份词表**——
13
+ // 此前两份各自维护,bash 那份漏了 `key` 段:`OPENAI_KEY` / `ANTHROPIC_KEY` / `GITHUB_KEY`
14
+ // 会原样透传给子进程,一条 `env` 就把密钥打进模型上下文。
15
+ // 唯一例外是 `pwd`:`PWD` 是每个 shell 都有的标准变量(不是凭据),
16
+ // 按它过滤会让子进程少一个常用变量 —— 所以 env 侧用下面这份去掉 pwd 的集合。
17
+ export const ENV_SECRET_SEGMENTS = new Set([...SECRET_NAME_SEGMENTS].filter((x) => x !== 'pwd'));
12
18
  const KEY_PREFIX = /(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,})/g;
13
19
 
14
20
  // v0.6.3(审计 H-1/H-2):**脱敏器自身的覆盖缺口**。
@@ -37,15 +43,20 @@ export function redactSecrets(/** @type {any} */ text) {
37
43
  // `x-api-key: …`(带 `api` 的复合名)。注释当时写着"覆盖 api_key/token/secret/password",
38
44
  // 读起来像覆盖了,实测一个都没中——**"有规则"不等于"规则认得"**。
39
45
  // 现在把名字按 `_`/`-` 分段,任一段是密钥词就掩码值(保留名字,便于排查"配了哪些")。
46
+ // v0.6.7(报告一 H-2,**三份报告都点到**):**键名可能带引号**。
47
+ // 原正则要求名字后面紧跟 `=`/`:`(只允许空白),于是 JSON/YAML 里最常见的形态全部漏过:
48
+ // `{"api_key": "sk-…"}`、`{'api_key': 'sk-…'}`、`api_key: "sk-…"`
49
+ // ——而诊断包是用户会主动贴到公开渠道的产物、审计日志是本机持久化的明文。
50
+ // 现在名字两侧允许成对的引号(`\1` 反向引用保证左右引号一致),值一侧的引号也允许。
40
51
  s = s.replace(
41
- /([A-Za-z0-9][A-Za-z0-9_-]{0,63})(\s*[=:]\s*)(["']?)([^\s"',}]{5,})/g,
42
- (/** @type {string} */ m, /** @type {string} */ name, /** @type {string} */ sep, /** @type {string} */ q, /** @type {string} */ val) => {
52
+ /(["']?)([A-Za-z0-9][A-Za-z0-9_-]{0,63})\1(\s*[=:]\s*)(["']?)([^\s"',}]{5,})/g,
53
+ (/** @type {string} */ m, /** @type {string} */ qn, /** @type {string} */ name, /** @type {string} */ sep, /** @type {string} */ qv, /** @type {string} */ val) => {
43
54
  const segs = String(name).toLowerCase().split(/[_-]+/);
44
55
  if (!segs.some((x) => SECRET_NAME_SEGMENTS.has(x))) return m;
45
56
  // 认证方案名本身不是凭据:`Authorization: Bearer <token>` 由上面那条 Bearer 规则
46
57
  // 负责掩掉 token,这里若连 `Bearer` 一起掩,审计行会退化成 `Authorization: *** ***`(可读性白损)
47
58
  if (/^(bearer|basic|token|digest)$/i.test(val)) return m;
48
- return `${name}${sep}${q}***`;
59
+ return `${qn}${name}${qn}${sep}${qv}***`;
49
60
  }
50
61
  );
51
62
  s = s.replace(/([?&](?:key|token|secret|api_key|access_token)=)[^&\s"']+/gi, '$1***');
@@ -0,0 +1,137 @@
1
+ // 灾难性回溯(ReDoS)判定的**单一来源**(v0.6.7,报告一 M-5 / M-14②)。
2
+ //
3
+ // 为什么单独成模块:同一个判定此前有**两份**实现,且都不完整——
4
+ // · `constraints.js` 只看嵌套量词(`(a+)+`),于是 `(a|aa)+` 装载即通过;
5
+ // · `tools/fs-tools.js` 的 `hasAmbiguousAlternation` 只扫**单层**括号(`\(([^()]*)\)`),
6
+ // 于是 `((a|ab)x)+` 连它也绕过去(报告实测:这类模式对长模型输出同步指数回溯,冻结进程)。
7
+ // 判定对象来自第三方 Pack / 用户配置(`pattern` 类约束),匹配对象是**模型输出**——
8
+ // 也就是可被外部文本影响的内容。两份实现里**最弱的那份说了算**,这正是本项目反复登记的根因。
9
+ //
10
+ // 口径:**装载时 fail-closed 拒绝**(宁可让作者改写 pattern,也不让内核在长输出上卡死)。
11
+ // 判定刻意保守——只拦"被量词修饰的组内出现歧义分支/嵌套量词"这类教科书形状,
12
+ // 不动 `(a|b)+`、`(ab)+`、`(a+)?` 这些常见且安全的写法。
13
+
14
+ /** 平衡括号扫描:返回每个括号组的 {start,end,body,quantified}(start/end 为 '(' ')' 的下标)
15
+ * @param {string} pattern */
16
+ function scanGroups(pattern) {
17
+ /** @type {{start:number,end:number,body:string,quantified:boolean}[]} */
18
+ const out = [];
19
+ const stack = [];
20
+ for (let i = 0; i < pattern.length; i += 1) {
21
+ const ch = pattern[i];
22
+ if (ch === '\\') {
23
+ i += 1; // 跳过转义字符
24
+ continue;
25
+ }
26
+ if (ch === '[') {
27
+ // 字符类内部不算分组(`[(]` 是字面量括号)
28
+ while (i < pattern.length && pattern[i] !== ']') {
29
+ if (pattern[i] === '\\') i += 1;
30
+ i += 1;
31
+ }
32
+ continue;
33
+ }
34
+ if (ch === '(') stack.push(i);
35
+ else if (ch === ')') {
36
+ const start = stack.pop();
37
+ if (start === undefined) continue;
38
+ // 组后是否紧跟**可重复**的量词(+ * {n,m})。刻意**不含 `?`**:`(a+)?` 只重复 0/1 次,
39
+ // 不会指数回溯——把它算进来会白白拒掉一批本来安全的 pattern(过严也会伤人)。
40
+ const j = i + 1;
41
+ const quantified = /[+*]/.test(pattern[j] || '') || /\{\d*,?\d*\}/.test(pattern.slice(j, j + 8));
42
+ out.push({ start, end: i, body: pattern.slice(start + 1, i), quantified });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** 被量词修饰的组内部还有量词吗(`(a+)+`、`(\d+)*`) */
49
+ export function hasNestedQuantifier(/** @type {string} */ pattern) {
50
+ return scanGroups(pattern).some((g) => g.quantified && /[+*]|\{\d*,?\d*\}/.test(g.body.replace(/^\?:/, '')));
51
+ }
52
+
53
+ /** 顶层拆分(跳过括号与字符类) */
54
+ function splitTopLevel(/** @type {string} */ body) {
55
+ const parts = [];
56
+ let depth = 0;
57
+ let cur = '';
58
+ for (let i = 0; i < body.length; i += 1) {
59
+ const ch = body[i];
60
+ if (ch === '\\') {
61
+ cur += ch + (body[i + 1] || '');
62
+ i += 1;
63
+ continue;
64
+ }
65
+ if (ch === '[') {
66
+ while (i < body.length && body[i] !== ']') {
67
+ if (body[i] === '\\') i += 1;
68
+ cur += body[i];
69
+ i += 1;
70
+ }
71
+ cur += body[i] || '';
72
+ continue;
73
+ }
74
+ if (ch === '(') depth += 1;
75
+ if (ch === ')') depth -= 1;
76
+ if (ch === '|' && depth === 0) {
77
+ parts.push(cur);
78
+ cur = '';
79
+ continue;
80
+ }
81
+ cur += ch;
82
+ }
83
+ parts.push(cur);
84
+ return parts;
85
+ }
86
+
87
+ /**
88
+ * 歧义分支(`(a|aa)+`、`((a|ab)x)+`):分支首字符相同,或一个是另一个的前缀。
89
+ *
90
+ * 关键改进(v0.6.7):**嵌套也算**——只要该组自身被量词修饰,或它嵌在某个被量词修饰的组里,
91
+ * 它内部的歧义分支就会在重复时指数爆炸。此前只扫单层括号,`((a|ab)x)+` 因此漏网。
92
+ * @param {string} pattern
93
+ */
94
+ export function hasAmbiguousAlternation(/** @type {string} */ pattern) {
95
+ const groups = scanGroups(pattern);
96
+ for (const g of groups) {
97
+ if (!g.body.includes('|')) continue;
98
+ const repeated = g.quantified || groups.some((o) => o.quantified && o.start < g.start && o.end > g.end);
99
+ if (!repeated) continue;
100
+ const branches = splitTopLevel(g.body)
101
+ .map((b) => b.replace(/^\?:/, '').trim())
102
+ .filter((b) => b !== '');
103
+ if (branches.length < 2) continue;
104
+ const firsts = branches.map((b) => b[0] || '');
105
+ if (new Set(firsts).size < firsts.length) return true;
106
+ if (branches.some((a) => branches.some((b) => a !== b && b.startsWith(a)))) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * pattern 的拒绝原因(null = 可用)。**约束引擎 / Pack 装载 / grep 工具共用这一个判据。**
113
+ * @param {any} pattern
114
+ * @returns {string|null}
115
+ */
116
+ export function patternRejectionReason(/** @type {any} */ pattern) {
117
+ if (typeof pattern !== 'string' || pattern === '') return 'pattern 必须是非空字符串';
118
+ try {
119
+ // eslint-disable-next-line no-new
120
+ new RegExp(pattern);
121
+ } catch (/** @type {any} */ e) {
122
+ return `正则无法编译:${e?.message || e}`;
123
+ }
124
+ if (pattern.length > 500) return 'pattern 过长(>500 字符)';
125
+ if (hasNestedQuantifier(pattern)) {
126
+ return 'pattern 含嵌套量词(如 (a+)+、(\\d+)*),在长文本上会指数级回溯——实测 29 字符即耗时数秒;请改写为不含嵌套量词的形式';
127
+ }
128
+ if (hasAmbiguousAlternation(pattern)) {
129
+ return 'pattern 含歧义分支 + 量词(如 (a|aa)+、(a|a?)+、((a|ab)x)+),重复时指数级回溯——实测 28 字符可耗时数十秒;请改写为无歧义的分支(如 (?:aa|a) 且调换顺序仍不行,请改用字符类或分段匹配)';
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** @param {any} pattern */
135
+ export function isSafePattern(/** @type {any} */ pattern) {
136
+ return patternRejectionReason(pattern) === null;
137
+ }