mingdao-harness 0.4.0 → 0.4.2

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/docs/CONFIG.md CHANGED
@@ -111,8 +111,21 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
111
111
  }
112
112
  ```
113
113
 
114
- 格式与 Claude Code 相同;工具以 `mcp__<服务器>__<工具>` 并入 Agent 循环,带 `readOnlyHint`
115
- 标注的工具自动放行。会话内 `/mcp` 查看状态;`mingdao mcp preset list/add` 一键接入常用服务器。
114
+ 格式与 Claude Code 相同;工具以 `mcp__<服务器>__<工具>` 并入 Agent 循环。
115
+
116
+ - **只读自动放行需显式授信(v0.4.1 P0)**:带 `readOnlyHint` 的工具默认**不再**自动放行——
117
+ 服务器可谎称只读绕过权限确认。只有加了 `"trusted": true` 的服务器,其 `readOnlyHint` 才被
118
+ 信任(只读档自动放行、ask 档不询问);未授信服务器的全部工具(含标注只读的)都走权限确认。
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/你的/目录"], "trusted": true }
124
+ }
125
+ }
126
+ ```
127
+
128
+ 会话内 `/mcp` 查看状态;`mingdao mcp preset list/add` 一键接入常用服务器。
116
129
 
117
130
  ## WebUI 服务器
118
131
 
@@ -253,6 +266,18 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
253
266
  { "timeout": { "firstTokenMs": 600000, "streamIdleMs": 120000, "totalMs": 1800000 } }
254
267
  ```
255
268
 
269
+ ### 文件访问边界(v0.4.1 P0 路径穿越防护)
270
+
271
+ read/write/edit/ls/glob/grep/undo 默认限定在**工作目录**内——auto 权限模式下,模型(或被提示注入
272
+ 诱导)也无法读 `~/.ssh`、`~/.mingdao/credentials.json`(API Key 明文)等越界文件;`realpath` 逐级
273
+ 校验防软链接逃逸。需要访问工作目录外时,显式加白名单:
274
+
275
+ ```json
276
+ { "fsAllowDirs": ["/home/you/projects/shared", "/tmp/build-output"] }
277
+ ```
278
+
279
+ 白名单目录同样受 `realpath` 校验(目录内的软链接指向白名单外仍拒绝)。
280
+
256
281
  ## 自定义 Provider 模块(非 OpenAI 兼容协议)
257
282
 
258
283
  在 `~/.mingdao/providers/<name>.mjs` 导出:
@@ -0,0 +1,65 @@
1
+ # v0.4.1 规划:安全版本(偿还 v0.4.0 审计安全债)
2
+
3
+ > 依据:`MingDao Harness v0.4.0 完整审计报告.docx`(2026-09-05,源码通读 + 4 项实测复现)。
4
+ > 定调:**暂停新功能,先清零 P0 安全债**——一个能自动执行 shell 的 agent,安全边界优先级高于任何新特性。
5
+ > 纪律:实现后不单独发布,与 v0.4.1 一起发布;等用户在 3820 验收后再发。
6
+
7
+ ## 已修复(本批次)
8
+
9
+ ### P0 安全(5 项)
10
+ 1. **路径穿越防护**(fs-tools.js):read/write/edit/ls/glob/grep/undo 限定工作目录 + `config.fsAllowDirs`
11
+ 白名单;`realpath` 逐级校验防软链接逃逸。auto 模式也无法读 ~/.ssh、credentials.json。
12
+ 2. **SSRF 302 重定向绕过**(fetch.js):`redirect:'manual'` 手动跟随,每跳重新 isPrivateHost + DNS 复检,
13
+ 跳数上限 5。
14
+ 3. **权限前缀规则绕过**(permissions.js):黑名单改白名单字符 `[A-Za-z0-9_ ./\\:-]`,单 `&`、重定向
15
+ `< >`、回车 `\r` 及一切 shell 元字符都不匹配前缀规则,回落权限确认。
16
+ 4. **预设 permission 提权**(presets.js):新增 `presetPermissionOverride`——预设声明 permission 若
17
+ 比当前更宽松(ask→auto 等)则忽略并提示;CLI/REPL/WebUI 三入口统一接入。
18
+ 5. **MCP 权限收紧**(mcp.js):`readOnlyHint` 自动放行改为「仅 `mcpServers.<name>.trusted:true` 才信任」,
19
+ 未授信服务器谎报只读也走权限确认。
20
+
21
+ ### P1 正确性(6 项)
22
+ 6. **turnToolCache 跨步去重失效**(agent.js):声明从 while 体内提到 for 轮内——去重跨步生效。
23
+ 7. **windowPressure 永不复位**(agent.js):压缩成功后复位,不再每轮 force 压缩持续烧钱。
24
+ 8. **mountConfigTools 阻塞事件循环**(tools/index.js):spawnSync → 异步 spawn + Promise。
25
+ 9. **batch.js 未跟进 model-caps**:超窗口预检改用 `resolveModelCaps(cfg, model).contextWindow`。
26
+ 10. **语义检索无匹配记忆全丢**(memory.js):无共同词时回退最近 N 条(相关性失败 ≠ 记忆不存在)。
27
+ 11. **沙箱探测缺陷**(bash.js):`detectSandbox` 从 `--version` 改为最小真实沙箱探测
28
+ (`bwrap --ro-bind / / --tmpfs /tmp true`),容器内 bwrap 不可用则降级 none。
29
+
30
+ ### P2 质量(6 项)
31
+ 12. 正则笔误 `/^f[c d]/` → `/^f[cd]/`(fetch.js + server.js)。
32
+ 13. git.js log/diff 限量实现(log 默认 -n 50、diff 默认 --stat),修注释与实现不符。
33
+ 14. cost-guard.js `todayCostWarned` TDZ 前置。
34
+ 15. registerTool 增 `readOnly` 选项(进 READONLY_TOOLS,只读档自动放行)。
35
+ 16. context.js 幂等守卫死代码移除(回收结果从不回写原 messages,守卫恒 false)。
36
+ 17. agent.js reasoning 清洗 O(n²) → 单次遍历 O(n)。
37
+
38
+ ### macOS 本地模型「输出截断 / 子代理无反馈」(追加修复,2026-09-05)
39
+
40
+ 用户 MacBook 用 mtplx-qwen38-27b(routing.enabled=true)复现,日志 `text=0` 且 `status=done`。两个根因:
41
+
42
+ 18. **子代理无反馈**(routing.js):`subagentModel` 在 routing 开启时恒返回 executor
43
+ (deepseek-v4-flash),无 routeTask 的「池外不干预」检查——本地/自定义模型派子代理时,把
44
+ executor 模型名发到本地 baseUrl(8081)→ 服务端不认识 → 400 → 子代理全灭。修复:池外跟随当前模型。
45
+ 19. **输出截断/text=0**(agent.js):兜底总结请求此前用 `trimMessages(messages, budget)` 全量历史,
46
+ 本地 q8 模型 ≈98k token 的 prefill 逼近/超过 600s 首 token 超时 → 总结请求失败被 `catch {}` 静默吞 →
47
+ 返回 text:null。修复:兜底总结改用轻量输入(system + 交付物清单 + 提示,几 k token),慢 prefill
48
+ 也能秒出总结;失败不再静默(io.print 提示原因)。
49
+ 20. 子代理空输出透出 note 原因(agent.js):不再笼统「(子任务无输出)」,主线程可据此决策。
50
+ 21. diagnose 报告新增「当前模型能力」段:本地模型未声明 contextWindow 时显式提示兜底 32k 及其后果
51
+ (输出/预算被压缩),帮用户自诊。
52
+
53
+ ## 顺延(明确记录,不强行塞入本批次)
54
+
55
+ - **WebUI 速率限制**:本地优先工具默认回环绑定 + token 认证已挡远程;已有 `MAX_CONCURRENT=8`
56
+ 并发上限 + `costGuard` 日费用上限双保险。令牌桶的维度(IP/会话/token)与阈值需产品决策,
57
+ 待 v0.4.2 单独设计。
58
+ - **容器环境 CI job**:审计建议加 container 矩阵覆盖 bwrap 不可用路径(沙箱探测修复已从代码层
59
+ 缓解,CI 覆盖作为后续工程项)。
60
+ - **省钱机制有效性自检**(/cost 展示各机制实际节省额):依赖更细的分账埋点,顺延。
61
+
62
+ ## 验收
63
+
64
+ - smoke 新增路径穿越/SSRF 重定向/权限绕过/预设提权/MCP trusted/语义检索回退等回归断言。
65
+ - 全绿门禁 + strict 0/0 + typecheck;发布前自检;用户 3820 验收后再随 v0.4.1 发布。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "local-audit",
3
3
  "label": "本地模型审计",
4
- "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + auto 权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
4
+ "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + 只读权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
5
5
  "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
6
  "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
7
- "permission": "auto",
7
+ "permission": "readonly",
8
8
  "maxRounds": 4,
9
9
  "maxOutputTokens": 4096
10
10
  }
package/src/agent.js CHANGED
@@ -123,7 +123,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
123
123
  const t0 = Date.now();
124
124
  const res = await subAgent.runTurn(messages);
125
125
  const ms = Date.now() - t0;
126
- const text = res.text || (res.truncated ? '(子任务达到步骤上限,未完成)' : '(子任务无输出)');
126
+ // v0.4.1:子代理空输出给主线程可用的失败信号(含 note 原因),而非笼统「无输出」——
127
+ // 主智能体据此决定是否重试/换法,而非把子代理静默当作「已完成但没说话」。
128
+ const text =
129
+ res.text ||
130
+ (res.truncated ? '(子任务达到步骤上限,未完成' : '(子任务无输出') +
131
+ (res.note ? ':' + res.note : '') + ')';
127
132
  io.print(style(` ↳ 子任务完成(${ms}ms)`, C.magenta));
128
133
  return text;
129
134
  }
@@ -209,12 +214,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
209
214
  }
210
215
  };
211
216
  try {
217
+ // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填。
218
+ // v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
219
+ // 去重只在本步的多个工具调用间生效,跨步(如先 grep 定位再 read 确认同一文件)完全失效。
220
+ const turnToolCache = new Map();
212
221
  for (round = 0; round < maxRounds; round++) {
213
222
  steps = 0;
214
223
  while (steps < stepLimit) {
215
224
  steps += 1;
216
- // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
217
- const turnToolCache = new Map();
218
225
  // 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
219
226
  // 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
220
227
  if (cfg.autoCompact !== false) {
@@ -225,7 +232,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
225
232
  count,
226
233
  provider,
227
234
  executorModel: subagentModel(cfg, modelName),
228
- triggerRatio: cfg.compactTrigger, // 可配置触发线(默认 80%)
235
+ // 可配置触发线:默认远程 80%;本地模型默认 60% 提前压缩——本地推理内存预算有限,
236
+ // 等到 80% 再压时上下文已累积过多、prefill 与内存双高(v0.4.2 本地模型 507 修复)
237
+ triggerRatio: Number(cfg.compactTrigger) > 0 ? cfg.compactTrigger : (caps.isLocal ? 0.6 : undefined),
229
238
  force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
230
239
  });
231
240
  if (compacted) {
@@ -240,6 +249,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
240
249
  C.dim
241
250
  )
242
251
  );
252
+ // v0.4.1 P1 修复:压缩成功后复位 windowPressure——此前永不复位,后续每轮都 force 压缩,
253
+ // 叠加 compact.js force 分支跳过 2000 token 最小阈值,增量段极短也发真实模型调用持续烧钱。
254
+ if (windowPressure) {
255
+ windowPressure = false;
256
+ pressureWarned = false;
257
+ }
243
258
  try {
244
259
  onCompact?.(messages);
245
260
  } catch {}
@@ -251,14 +266,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
251
266
  // reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
252
267
  // reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
253
268
  // 多轮工具会话必崩);仅纯文本回复的 reasoning 可裁剪(不回传也不影响)。
269
+ // v0.4.1 P2 修复:单次遍历构建新数组(O(n)),此前循环内每次替换都 sanitized.map 全量重建(O(n²))。
254
270
  let sanitized = trimmed;
255
- for (const m of sanitized) {
271
+ let dirty = false;
272
+ for (let i = 0; i < sanitized.length; i++) {
273
+ const m = sanitized[i];
256
274
  const rc = m.reasoning_content;
257
275
  if (typeof rc !== 'string' || (Array.isArray(m.tool_calls) && m.tool_calls.length)) continue;
276
+ let replacement = null;
258
277
  if (rc.length > 4000) {
259
- sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
278
+ replacement = `[思考过程已省略(原 ${rc.length} 字)]`;
260
279
  } else if (rc.length > 1000) {
261
- sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
280
+ replacement = rc.slice(-500) + ' …[思考过程已截断]';
281
+ }
282
+ if (replacement !== null) {
283
+ if (!dirty) {
284
+ sanitized = sanitized.slice();
285
+ dirty = true;
286
+ }
287
+ sanitized[i] = { ...m, reasoning_content: replacement };
262
288
  }
263
289
  }
264
290
 
@@ -432,6 +458,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
432
458
  // 其余模式/工具保持串行,避免多个权限对话框交错。事件顺序(start/render/post/回填)不变。
433
459
  const READONLY_BATCH = new Set(['read', 'ls', 'glob', 'grep']);
434
460
  const canBatch = permission.mode === 'auto';
461
+ // v0.4.2(本地模型 507 memory_refusal 修复):子代理目标为本地模型时,只读子代理不并行——
462
+ // 多路大 prefill 同时冲进本地推理服务会击穿其单进程内存预算(507 memory_refusal),
463
+ // 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
464
+ const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
435
465
 
436
466
  // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
437
467
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
@@ -584,7 +614,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
584
614
  const prep = /** @type {any} */ (await prepTool(tc));
585
615
  const name = tc.function?.name || '';
586
616
  let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
587
- if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
617
+ if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
588
618
  batch.push({ prep, batchable });
589
619
  i += 1;
590
620
  if (!batchable) break;
@@ -717,13 +747,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
717
747
  stripOrphanCalls();
718
748
  // v0.2.8 兜底总结(对齐 DSH):跑满步数/末轮无正文且未中断时,补一次 no-tool 小输出请求,
719
749
  // 让任务以「总结文字 + 交付物清单」收尾,而非静默结束;失败则回退旧行为(text:null)。
750
+ // v0.4.1 修复:输入轻量化——此前用 trimMessages(messages, budget) 全量历史,本地 q8 量化模型
751
+ // (prefill ~165 tok/s)≈98k token 的 prefill 逼近/超过 600s 首 token 超时 → 总结请求失败被吞 →
752
+ // 表现为「输出截断/子代理无反馈」。改用 system + 交付物清单 + 提示(几 k token),慢 prefill 也能秒出总结。
720
753
  if (!aborted && messages.length) {
721
754
  try {
722
- // stripOrphanCalls 残留的空 assistant(无 tool_calls content 为空)回传会触发 API 400,先清掉
723
- const lastMsg = messages[messages.length - 1];
724
- if (lastMsg.role === 'assistant' && !lastMsg.content && !Array.isArray(lastMsg.tool_calls)) messages.pop();
755
+ const sys = messages.find((/** @type {any} */ m) => m.role === 'system');
725
756
  const wrapReq = [
726
- ...trimMessages(messages, budget, count),
757
+ ...(sys ? [sys] : []),
758
+ ...(deliverables.length
759
+ ? [{ role: 'user', content: '已交付文件:\n' + deliverables.map((/** @type {string} */ f) => `- ${f}`).join('\n') }]
760
+ : []),
727
761
  { role: 'user', content: '(系统提示)任务已执行完毕。请用一段话总结刚才完成的工作,列出交付物(文件路径),并说明遗留问题与后续建议。' },
728
762
  ];
729
763
  currentAc = new AbortController();
@@ -746,7 +780,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
746
780
  if (wrapRes.text) messages.push({ role: 'assistant', content: wrapRes.text });
747
781
  // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
748
782
  return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
749
- } catch {}
783
+ } catch (/** @type {any} */ err) {
784
+ // v0.4.1:不再静默吞异常——总结失败原因透出,便于定位(此前用户只见「输出截断/无反馈」)
785
+ try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
786
+ }
750
787
  }
751
788
  return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
752
789
  }
package/src/batch.js CHANGED
@@ -13,7 +13,6 @@ import { resolveProviderConfig } from './providers/index.js';
13
13
  import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
14
14
  import { recordCacheStats } from './cachestats.js';
15
15
  import { approxTokens } from './context.js';
16
- import { modelPreset } from './models.js';
17
16
 
18
17
  const DEFAULT_WINDOW = '24h';
19
18
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
@@ -120,8 +119,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
120
119
  if (deduped > 0) onStatus?.(`去重:${deduped} 条重复问题合并(${list.length} → ${unique.length} 条实际提交)`);
121
120
 
122
121
  // —— 省钱 B2:单问超窗口预检(估算输入 + max_tokens 超过模型窗口 95% 即报错,绝不提交烧钱)——
123
- const preset = modelPreset(model) || {};
124
- const windowTokens = Number(preset.budgetTokens) || 128000;
122
+ // v0.4.1 P1 修复:接入 resolveModelCaps 取真实 contextWindow(此前用 preset.budgetTokens 当窗口,
123
+ // 本地小模型 32k 窗口会套 128000 默认 → 超窗口预检完全失效,提交后烧钱且失败)。
124
+ const { resolveModelCaps } = await import('./model-caps.js');
125
+ const windowTokens = resolveModelCaps(cfg, model).contextWindow;
125
126
  const sysTokens = approxTokens(systemPrompt);
126
127
  for (let i = 0; i < unique.length; i++) {
127
128
  const est = sysTokens + approxTokens(unique[i]);
package/src/cli.js CHANGED
@@ -362,13 +362,21 @@ async function main() {
362
362
  let presetBlock = '';
363
363
  let presetOverlay = /** @type {Record<string, any>} */ ({});
364
364
  if (opts.preset) {
365
- const { loadPreset, presetConfigOverrides, presetSystemBlock, listPresets } = await import('./presets.js');
365
+ const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride, listPresets } = await import('./presets.js');
366
366
  activePreset = loadPreset(workingDir, opts.preset);
367
367
  if (!activePreset) {
368
368
  const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
369
369
  io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
370
370
  } else {
371
371
  presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
372
+ // P0(v0.4.1):预设 permission 提权防护——预设不得把 ask/readonly 静默改成 auto
373
+ const permOv = presetPermissionOverride(activePreset, cfg.permission ?? 'ask');
374
+ if (permOv.escalated) {
375
+ delete presetOverlay.permission;
376
+ io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
377
+ } else if (activePreset.permission !== undefined) {
378
+ presetOverlay.permission = permOv.permission;
379
+ }
372
380
  if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
373
381
  presetBlock = presetSystemBlock(activePreset);
374
382
  io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
@@ -52,6 +52,21 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
52
52
  const cfg = loadConfig();
53
53
  push(cfg ? redactSensitive(JSON.stringify(cfg, null, 2)) : '(不存在或读取失败)');
54
54
 
55
+ // v0.4.1:模型能力定位——本地模型未声明 contextWindow 会兜底 32k → maxOutput/budget 偏小,
56
+ // 表现为「输出截断/频繁压缩」。此处显式提示,帮用户自诊。
57
+ if (cfg?.model) {
58
+ push('');
59
+ push('## 当前模型能力');
60
+ try {
61
+ const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
62
+ const caps = resolveModelCaps(cfg, cfg.model);
63
+ push(`- 模型:${cfg.model}(${caps.isLocal ? '本地部署' : '远程'})`);
64
+ push(`- 上下文窗口:${caps.contextWindow}${caps.isLocal && !((cfg.customModels || {})[cfg.model]?.contextWindow) ? '(未在 customModels 声明 contextWindow,按本地兜底 32k——建议声明真实窗口,如 131072,否则输出/预算被压缩)' : ''}`);
65
+ push(`- 最大输出:${caps.maxOutputTokens} · 预算:${safeBudget(cfg, caps)}`);
66
+ push(`- 自动路由:${cfg.routing?.enabled ? '开(planner=' + cfg.routing.planner + ' executor=' + cfg.routing.executor + ')' : '关'}`);
67
+ } catch {}
68
+ }
69
+
55
70
  push('');
56
71
  push('## 凭证库');
57
72
  push(`${redactSensitive(credentialsPath())}:${fs.existsSync(credentialsPath()) ? '已存在(内容不打包)' : '无'}`);
@@ -316,7 +316,7 @@ export async function runRepl(ctx) {
316
316
  io.print('已清空上下文(会话文件已同步重置)。');
317
317
  } else if (cmd === '/preset') {
318
318
  // v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
319
- const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
319
+ const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
320
320
  if (!arg) {
321
321
  const ps = listPresets(workingDir);
322
322
  if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
@@ -332,6 +332,14 @@ export async function runRepl(ctx) {
332
332
  const over = presetConfigOverrides(p);
333
333
  // 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
334
334
  // 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
335
+ // P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
336
+ const permOv = presetPermissionOverride(p, cfg.permission ?? 'ask');
337
+ if (permOv.escalated) {
338
+ delete over.permission;
339
+ io.print(style(`⚠ 预设 "${p.name}" 声明 permission=${p.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
340
+ } else if (p.permission !== undefined) {
341
+ over.permission = permOv.permission;
342
+ }
335
343
  agentCfg = { ...cfg, ...over, presetName: p.name };
336
344
  if (over.model) await switchToModel(over.model, { silent: true, persist: false });
337
345
  presetBlock = presetSystemBlock(p);
package/src/context.js CHANGED
@@ -88,9 +88,9 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
88
88
  if (total <= budget) break;
89
89
  const m = out[i];
90
90
  const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
91
- // 幂等守卫(A4):已回收过的内容跳过——否则「截断标记」把长度再次顶过阈值,逐轮嵌套重截,
92
- // 被保留消息字节逐轮漂移(前缀缓存连尾部都失配)
93
- if (c.includes('已回收')) continue;
91
+ // 注:无幂等守卫——回收结果只写在投影副本 out 上、从不回写原 messages(trimMessages 是投影,
92
+ // 每次调用从原 messages 重新拷贝),故「已回收」标记不会进入下一次调用的输入,无需守卫。
93
+ // 截断逻辑确定(同输入同输出),字节稳定,不会逐轮嵌套重截(审计 v0.4.1 确认的死代码已移除)。
94
94
  let next = null;
95
95
  if (m.role === 'tool' && c.length > 40) {
96
96
  next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
package/src/cost-guard.js CHANGED
@@ -17,6 +17,9 @@ export function costGuardConfig() {
17
17
  // 质检 M10:按 cache-stats 文件 mtime 缓存统计结果——文件未变直接复用,
18
18
  // 避免 Agent 每步对整文件(可能数千行)重复解析与累加。
19
19
  let todayCache = { mtime: -1, size: -1, start: 0, sum: 0 };
20
+ // v0.4.1 P2 修复(TDZ):todayCostWarned 提到 todayCost 使用点之前声明——
21
+ // 此前在第 44 行才 let 声明、第 36 行已使用(模块加载顺序碰巧掩盖了风险,循环依赖场景会抛 ReferenceError)。
22
+ let todayCostWarned = false;
20
23
  export function todayCost() {
21
24
  const start = beijingDayStart().getTime();
22
25
  try {
@@ -40,7 +43,6 @@ export function todayCost() {
40
43
  return null;
41
44
  }
42
45
  }
43
- let todayCostWarned = false;
44
46
 
45
47
  export function costGuardStatus() {
46
48
  const g = costGuardConfig();
package/src/mcp.js CHANGED
@@ -25,11 +25,12 @@ const HANDSHAKE_TIMEOUT_MS = 20000;
25
25
  let nextId = 1;
26
26
 
27
27
  export class McpClient {
28
- constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {} }, /** @type {any} */ workingDir) {
28
+ constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir) {
29
29
  this.name = name;
30
30
  this.command = command;
31
31
  this.args = args;
32
32
  this.env = env;
33
+ this.trusted = trusted === true; // v0.4.1 P0:仅 trusted 服务器的 readOnlyHint 才被信任自动放行
33
34
  this.workingDir = workingDir;
34
35
  this.tools = /** @type {any[]} */ ([]);
35
36
  this.child = null;
@@ -262,7 +263,11 @@ export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any}
262
263
  },
263
264
  isReadonly(/** @type {any} */ prefixedName) {
264
265
  const found = this.lookup(prefixedName);
265
- return found ? found.client.isReadonly(found.toolName) : false;
266
+ if (!found) return false;
267
+ // v0.4.1 P0:未授信服务器(未设 mcpServers.<name>.trusted)即使工具标注 readOnlyHint 也不自动放行——
268
+ // 服务器可谎称只读绕过权限确认;只有显式 trusted 才信任其标注。
269
+ if (!found.client.trusted) return false;
270
+ return found.client.isReadonly(found.toolName);
266
271
  },
267
272
  status() {
268
273
  const rows = [];
package/src/memory.js CHANGED
@@ -255,7 +255,11 @@ export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} *
255
255
  return { e, score: overlap / union };
256
256
  });
257
257
  scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
258
- return scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
258
+ const hits = scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
259
+ // v0.4.1 P1 修复:无匹配时回退最近 N 条(entries 按「旧→新」追加,尾部即最新)——此前 filter(score>0)
260
+ // 在 query 与记忆无共同词时返回空数组,项目记忆完全消失(相关性判断失败 ≠ 记忆不存在)。
261
+ if (hits.length) return hits;
262
+ return entries.slice(-topN);
259
263
  }
260
264
 
261
265
  export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
@@ -31,8 +31,10 @@ function ruleMatches(rule, name, args) {
31
31
  if (ruleName !== name) return false;
32
32
  const want = rule.slice(idx + 1).trim();
33
33
  const have = summarize(name, args).trim();
34
- // bash 前缀规则防链式命令绕过:含 && / ; / | / ` / $( 的复合命令不匹配前缀规则,回落权限确认
35
- if (name === 'bash' && /&&|\|\||[;|`]|\$\(|\n/.test(have)) return false;
34
+ // bash 前缀规则防链式命令绕过(P0 安全,v0.4.1):白名单字符法——前缀规则仅匹配
35
+ // 单条简单命令(字母数字/空格/下划线/./-/),任何 shell 元字符(&&/||/;|&<>`$()\n\r)都不匹配,
36
+ // 回落权限确认。黑名单枚举永远追不上绕过手法(单 & 后台串联、重定向、\r 等),白名单才可靠。
37
+ if (name === 'bash' && !/^[A-Za-z0-9_ ./\\:-]+$/.test(have)) return false;
36
38
  if (want.endsWith('*')) return have.startsWith(want.slice(0, -1));
37
39
  return have === want;
38
40
  }
package/src/presets.js CHANGED
@@ -136,6 +136,7 @@ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ nam
136
136
  /**
137
137
  * 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
138
138
  * tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
139
+ * 注意:permission 提权由调用方用 presetPermissionOverride 过滤(防项目级预设提权,见下)。
139
140
  */
140
141
  export function presetConfigOverrides(/** @type {any} */ preset) {
141
142
  const out = /** @type {Record<string, any>} */ ({});
@@ -146,6 +147,26 @@ export function presetConfigOverrides(/** @type {any} */ preset) {
146
147
  return out;
147
148
  }
148
149
 
150
+ // 权限宽松度排序(数值越大越宽松/越危险)。
151
+ const PERM_RANK = /** @type {Record<string, number>} */ ({ readonly: 0, ask: 1, auto: 2 });
152
+
153
+ /**
154
+ * 预设 permission 提权防护(P0 安全,v0.4.1):预设声明的 permission 不得比当前配置更宽松
155
+ * (如当前 ask → 预设 auto 属提权,忽略并返回当前值)。clone 恶意仓库含 .mingdao/presets/*.json
156
+ * 声明 auto 时,不能静默跳过用户全部确认。返回 { permission, escalated }:escalated=true 表示已拦截提权。
157
+ * @param {any} preset @param {string} currentPermission
158
+ */
159
+ export function presetPermissionOverride(/** @type {any} */ preset, /** @type {any} */ currentPermission) {
160
+ const want = preset && preset.permission !== undefined ? String(preset.permission) : null;
161
+ if (!want || !(want in PERM_RANK)) return { permission: currentPermission, escalated: false };
162
+ const cur = currentPermission in PERM_RANK ? currentPermission : 'ask';
163
+ if (PERM_RANK[want] > PERM_RANK[cur]) {
164
+ // 提权:忽略预设值,保持当前更保守的权限
165
+ return { permission: cur, escalated: true };
166
+ }
167
+ return { permission: want, escalated: false };
168
+ }
169
+
149
170
  /** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
150
171
  export function presetSystemBlock(/** @type {any} */ preset) {
151
172
  const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
@@ -49,8 +49,10 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
49
49
  const j = JSON.parse(raw);
50
50
  if (j?.error?.message) detail = j.error.message;
51
51
  } catch {}
52
+ // 本地模型内存不足(507 memory_refusal):给可操作降级提示而非裸状态码(v0.4.2 本地模型 507 修复)
53
+ const hint = res.status === 507 ? '(本地模型内存不足:请压缩上下文、减少并发子任务,或重启模型服务释放内存)' : '';
52
54
  /** @type {ApiError} */
53
- const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}`);
55
+ const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}${hint}`);
54
56
  e.status = res.status;
55
57
  e.headers = res.headers; // 重试退避读取 Retry-After 用
56
58
  throw e;
package/src/routing.js CHANGED
@@ -139,9 +139,13 @@ export async function routeTask(/** @type {any} */ { cfg, provider, currentModel
139
139
  return rc.executor === currentModel ? { model: currentModel, reason: null } : { model: rc.executor, reason: '回退执行模型' };
140
140
  }
141
141
 
142
- // 子代理模型选择:路由开启时固定 executor
142
+ // 子代理模型选择:路由开启时固定 executor——但当前模型不在路由池(如本地/自定义模型)时
143
+ // 必须跟随当前模型,否则子代理会把 executor 模型名(deepseek-v4-flash)发到当前模型的 baseUrl
144
+ // (本地 8081),服务端不认识 → 400 → 子代理全灭,表现为主线程「子代理无反馈」。
143
145
  export function subagentModel(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
144
146
  const rc = routingConfig(cfg);
145
147
  if (!rc) return currentModel;
148
+ // v0.4.1 修复:与 routeTask 的池外检查一致——当前模型不在 planner/executor 池内时不干预
149
+ if (currentModel !== rc.planner && currentModel !== rc.executor) return currentModel;
146
150
  return modelPreset(rc.executor) ? rc.executor : currentModel;
147
151
  }
package/src/tools/bash.js CHANGED
@@ -34,8 +34,16 @@ export function detectSandbox() {
34
34
  sandboxSupport = 'none';
35
35
  if (process.platform === 'linux') {
36
36
  try {
37
- const r = spawnSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 });
38
- sandboxSupport = r.error ? 'none' : 'bwrap';
37
+ // v0.4.1 P1 修复:实际能力探测而非仅 --version——bwrap 存在但 /proc 挂载受限(Docker/devcontainer/
38
+ // CI 流水线等容器环境)时 --version 能跑、真正建沙箱却失败。用最小真实沙箱命令验证:
39
+ // --ro-bind / / --tmpfs /tmp true 能成功退出 0 才算可用,否则降级 none(runBash 会注明降级)。
40
+ const v = spawnSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 });
41
+ if (v.error) {
42
+ sandboxSupport = 'none';
43
+ } else {
44
+ const probe = spawnSync('bwrap', ['--die-with-parent', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', 'true'], { stdio: 'ignore', timeout: 5000 });
45
+ sandboxSupport = probe.error || probe.status !== 0 ? 'none' : 'bwrap';
46
+ }
39
47
  } catch {
40
48
  sandboxSupport = 'none';
41
49
  }
@@ -9,7 +9,7 @@ function isPrivateHost(/** @type {string} */ hostname) {
9
9
  if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
10
10
  if (h.includes(':')) {
11
11
  if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
12
- return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
12
+ return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
13
13
  }
14
14
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
15
15
  if (!m) return false;
@@ -39,10 +39,42 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
39
39
  }
40
40
  }
41
41
  if (blocked) return { ok: false, error: `拒绝访问内网/本机地址(${host})——SSRF 防护。` };
42
+ // 302 重定向复检(P0 安全,v0.4.1):redirect:'follow' 只检查初始 URL,攻击者可用公网 302 跳回内网。
43
+ // 改 redirect:'manual' 手动跟随,每一跳重新 isPrivateHost + DNS 复检,跳数上限 5。
42
44
  const ac = new AbortController();
43
45
  const timer = setTimeout(() => ac.abort(), 15000);
44
46
  try {
45
- const res = await fetch(u, { signal: ac.signal, redirect: 'follow' });
47
+ let cur = u;
48
+ let res = /** @type {any} */ (null);
49
+ for (let hop = 0; hop <= 5; hop++) {
50
+ if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
51
+ const ch = String(cur.hostname || '').toLowerCase();
52
+ let hopBlocked = isPrivateHost(ch);
53
+ if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
54
+ try {
55
+ const addrs = await lookup(ch, { all: true, verbatim: true });
56
+ hopBlocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
57
+ } catch {
58
+ // DNS 解析失败:放行,连接阶段会报错
59
+ }
60
+ }
61
+ if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
62
+ res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
63
+ if (res.status >= 300 && res.status < 400) {
64
+ const loc = res.headers.get('location');
65
+ if (!loc) break; // 无 Location:按最终响应处理
66
+ try {
67
+ cur = new URL(loc, cur);
68
+ } catch {
69
+ return { ok: false, error: `非法重定向地址:${loc}` };
70
+ }
71
+ if (cur.protocol !== 'http:' && cur.protocol !== 'https:') {
72
+ return { ok: false, error: '重定向到非 http(s) 地址,已拒绝。' };
73
+ }
74
+ continue;
75
+ }
76
+ break;
77
+ }
46
78
  const buf = await res.arrayBuffer();
47
79
  clearTimeout(timer);
48
80
  if (buf.byteLength > 512 * 1024) return { ok: false, error: `响应超过 512KB 上限(实际 ${buf.byteLength} 字节)。` };
@@ -19,6 +19,53 @@ function resolvePath(cwd, p) {
19
19
  return path.resolve(cwd, p);
20
20
  }
21
21
 
22
+ // —— 路径穿越防护(P0 安全,v0.4.1)——
23
+ // 文件工具限定在「工作目录 + config.fsAllowDirs 白名单」内;realpath 逐级校验防软链接逃逸。
24
+ // auto 模式下模型(或被提示注入诱导)也无法 read ~/.ssh、~/.mingdao/credentials.json 等越界文件。
25
+ // 白名单:config.json 的 fsAllowDirs 数组(绝对目录),需要访问工作目录外时显式添加。
26
+ function normCmp(/** @type {any} */ p) {
27
+ return process.platform === 'win32' ? String(p).toLowerCase() : String(p);
28
+ }
29
+ function realPathOrNull(/** @type {any} */ p) {
30
+ try {
31
+ return normCmp(fs.realpathSync(p));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ // 判断 target 是否在 root 内:对 target 逐级向上找最近的已存在祖先 realpath,再与 root realpath 比较。
37
+ // (write 新文件时父目录可能尚不存在,逐级向上即可正确处理;软链接已被 realpath 展开。)
38
+ function withinRoot(/** @type {any} */ root, /** @type {any} */ target) {
39
+ const rr = realPathOrNull(root);
40
+ if (!rr) return false;
41
+ let cur = target;
42
+ for (let i = 0; i < 64; i++) {
43
+ const rp = realPathOrNull(cur);
44
+ if (rp) return rp === rr || rp.startsWith(rr + path.sep);
45
+ const parent = path.dirname(cur);
46
+ if (parent === cur) return false;
47
+ cur = parent;
48
+ }
49
+ return false;
50
+ }
51
+ /**
52
+ * 解析路径并做工作目录边界检查。
53
+ * @param {any} ctx
54
+ * @param {any} p
55
+ * @returns {{ ok: true, path: string } | { ok: false, error: string }}
56
+ */
57
+ function boundedPath(/** @type {any} */ ctx, /** @type {any} */ p) {
58
+ const cwd = ctx?.cwd || ctx?.workingDir || process.cwd();
59
+ const raw = resolvePath(cwd, p ?? '');
60
+ if (!raw) return { ok: false, error: '缺少 path 参数。' };
61
+ const allowDirs = Array.isArray(ctx?.cfg?.fsAllowDirs) ? ctx.cfg.fsAllowDirs : [];
62
+ const roots = [cwd, ...allowDirs.map((/** @type {any} */ d) => path.resolve(String(d)))];
63
+ for (const r of roots) {
64
+ if (withinRoot(r, raw)) return { ok: true, path: raw };
65
+ }
66
+ return { ok: false, error: `路径越界(工作目录外):${raw}。如需访问请将目录加入 config.fsAllowDirs 白名单。` };
67
+ }
68
+
22
69
  /**
23
70
  * @param {any} buf
24
71
  */
@@ -76,7 +123,7 @@ function backup(ctx, p) {
76
123
  export function undo(args, ctx) {
77
124
  const store = ctx?.undoStore?.backups;
78
125
  if (!(store instanceof Map) || !store.size) return { ok: false, error: '没有可撤销的修改。' };
79
- const p = args.path ? resolvePath(ctx.cwd, args.path) : null;
126
+ const p = args.path ? (() => { const bb = boundedPath(ctx, args.path); return bb.ok ? bb.path : null; })() : null;
80
127
  if (p) {
81
128
  const list = store.get(p);
82
129
  if (!list?.length) return { ok: false, error: `${p} 没有可撤销的修改记录。` };
@@ -124,8 +171,9 @@ export function invalidateReadCache(p) {
124
171
  */
125
172
  export function read(args, ctx) {
126
173
  try {
127
- const p = resolvePath(ctx.cwd, args.path ?? '');
128
- if (!p) return { ok: false, error: '缺少 path 参数。' };
174
+ const b = boundedPath(ctx, args.path ?? '');
175
+ if (!b.ok) return b;
176
+ const p = b.path;
129
177
  const st = fs.statSync(p);
130
178
  if (st.isDirectory()) return { ok: false, error: `"${p}" 是目录,请使用 ls 查看。` };
131
179
  if (st.size > MAX_FILE_BYTES) {
@@ -181,8 +229,9 @@ const MAX_WRITE_BYTES = 2 * 1024 * 1024; // write 单次内容上限(防模型
181
229
  */
182
230
  export function write(args, ctx) {
183
231
  try {
184
- const p = resolvePath(ctx.cwd, args.path ?? '');
185
- if (!p) return { ok: false, error: '缺少 path 参数。' };
232
+ const b = boundedPath(ctx, args.path ?? '');
233
+ if (!b.ok) return b;
234
+ const p = b.path;
186
235
  const content = String(args.content ?? '');
187
236
  if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
188
237
  return { ok: false, error: `内容超过 ${MAX_WRITE_BYTES / 1024 / 1024}MB 上限,请分多次写入。` };
@@ -222,11 +271,12 @@ function regionAround(text, lineStart, lineCount, context = 2) {
222
271
  */
223
272
  export function edit(args, ctx) {
224
273
  try {
225
- const p = resolvePath(ctx.cwd, args.path ?? '');
274
+ const b = boundedPath(ctx, args.path ?? '');
275
+ if (!b.ok) return b;
276
+ const p = b.path;
226
277
  const oldString = String(args.old_string ?? '');
227
278
  const newString = String(args.new_string ?? '');
228
279
  const replaceAll = Boolean(args.replace_all);
229
- if (!p) return { ok: false, error: '缺少 path 参数。' };
230
280
  if (!oldString) return { ok: false, error: '缺少 old_string 参数。' };
231
281
  try {
232
282
  if (fs.statSync(p).size > MAX_FILE_BYTES) {
@@ -265,7 +315,9 @@ export function edit(args, ctx) {
265
315
  */
266
316
  export function ls(args, ctx) {
267
317
  try {
268
- const p = resolvePath(ctx.cwd, args.path || '.');
318
+ const b = boundedPath(ctx, args.path || '.');
319
+ if (!b.ok) return b;
320
+ const p = b.path;
269
321
  const entries = fs.readdirSync(p, { withFileTypes: true });
270
322
  const rows = entries
271
323
  .map((e) => {
@@ -354,7 +406,9 @@ function walkFiles(root, visitor) {
354
406
  export function glob(args, ctx) {
355
407
  try {
356
408
  const pattern = String(args.pattern ?? '*');
357
- const root = resolvePath(ctx.cwd, args.path || '.');
409
+ const b = boundedPath(ctx, args.path || '.');
410
+ if (!b.ok) return b;
411
+ const root = b.path;
358
412
  if (!fs.existsSync(root)) return { ok: false, error: `目录不存在:${root}` };
359
413
  const re = globToRegExp(pattern);
360
414
  const matchRel = pattern.includes('/');
@@ -401,7 +455,9 @@ export function grep(args, ctx) {
401
455
  const MAX_LINE = 20 * 1024;
402
456
  /** @type {(line: any) => any} */
403
457
  const testLine = (line) => (line.length > MAX_LINE ? re.test(line.slice(0, MAX_LINE)) : re.test(line));
404
- const root = resolvePath(ctx.cwd, args.path || '.');
458
+ const root0 = boundedPath(ctx, args.path || '.');
459
+ if (!root0.ok) return root0;
460
+ const root = root0.path;
405
461
  const include = args.include ? globToRegExp(String(args.include)) : null;
406
462
  /** @type {any[]} */
407
463
  const matches = [];
package/src/tools/git.js CHANGED
@@ -14,10 +14,18 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
14
14
  if (!GIT_READONLY.has(sub)) {
15
15
  return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
16
16
  }
17
- // 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count
17
+ // 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
18
+ // v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
19
+ let effectiveArgv = argv;
20
+ if ((sub === 'log' || sub === 'shortlog') && !argv.some((/** @type {string} */ a) => /^-(n|\d+)$/.test(a) || a === '--max-count')) {
21
+ effectiveArgv = [...argv, '-n', '50'];
22
+ } else if (sub === 'diff' && !argv.some((/** @type {string} */ a) => a === '--stat')) {
23
+ // diff 全量可能极巨:默认 --stat 概览,模型需要全文再显式加 --no-stat 或指定文件
24
+ effectiveArgv = [...argv, '--stat'];
25
+ }
18
26
  const cwd = ctx.workingDir || process.cwd();
19
27
  try {
20
- const { stdout, stderr } = await execFile('git', argv, {
28
+ const { stdout, stderr } = await execFile('git', effectiveArgv, {
21
29
  cwd,
22
30
  timeout: 15000,
23
31
  maxBuffer: 4 * 1024 * 1024,
@@ -6,7 +6,7 @@ import { runBash } from './bash.js';
6
6
  import { runGit } from './git.js';
7
7
  import { runFetch } from './fetch.js';
8
8
  import { listSkills, loadSkill } from '../skills.js';
9
- import { spawnSync } from 'node:child_process';
9
+ import { spawn } from 'node:child_process';
10
10
 
11
11
  // 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
12
12
  export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
@@ -254,7 +254,9 @@ const CUSTOM_TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
254
254
 
255
255
  /**
256
256
  * 注册第三方工具(进程级,重复注册同名会报错——避免静默覆盖)。
257
- * @param {{ name: string, description?: string, parameters?: any, run: (args: any, ctx: any) => any | Promise<any> }} tool
257
+ * readOnly(v0.4.1 P2 新增):标注后进 READONLY_TOOLS 集合——只读档自动放行、ask 档不询问、
258
+ * 只读子代理可用。默认 false(写类/副作用工具仍走权限询问)。
259
+ * @param {{ name: string, description?: string, parameters?: any, readOnly?: boolean, run: (args: any, ctx: any) => any | Promise<any> }} tool
258
260
  */
259
261
  export function registerTool(/** @type {any} */ tool) {
260
262
  const name = String(tool?.name ?? '').trim();
@@ -279,11 +281,18 @@ export function registerTool(/** @type {any} */ tool) {
279
281
  function: { name, description: String(tool.description || ''), parameters },
280
282
  },
281
283
  run: tool.run,
284
+ readOnly: tool.readOnly === true,
282
285
  };
283
286
  customTools.set(name, entry);
287
+ if (tool.readOnly === true) READONLY_TOOLS.add(name);
284
288
  return entry.schema;
285
289
  }
286
290
 
291
+ /** 判断第三方工具是否标注只读。 */
292
+ export function isRegisteredToolReadonly(/** @type {any} */ name) {
293
+ return customTools.get(name)?.readOnly === true;
294
+ }
295
+
287
296
  /** 已注册的第三方工具名列表。 */
288
297
  export function listRegisteredTools() {
289
298
  return [...customTools.keys()];
@@ -318,26 +327,49 @@ export function mountConfigTools(/** @type {any} */ cfg) {
318
327
  name,
319
328
  description: String(e.description || ''),
320
329
  parameters: e.parameters,
321
- run: (/** @type {any} */ args, /** @type {any} */ ctx) => {
322
- const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
323
- const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', e.command] : ['-lc', e.command];
324
- const r = spawnSync(shell, shellArgs, {
325
- cwd: ctx.cwd,
326
- env: { ...process.env, MINGDAO_TOOL_ARGS: JSON.stringify(args ?? {}) },
327
- timeout: Math.min(Number(e.timeout) > 0 ? Number(e.timeout) : 120, 600) * 1000,
328
- maxBuffer: 2 * 1024 * 1024,
329
- });
330
- const out = String(r.stdout || '');
331
- const err = String(r.stderr || '');
332
- const capped = out.length > 20000 ? out.slice(0, 20000) + `\n…[输出过长已截断,共 ${out.length} 字]` : out;
333
- return {
334
- ok: r.error ? false : (r.status ?? 0) === 0,
335
- exitCode: r.error ? null : (r.status ?? null),
336
- output: (capped || '(无输出)').trim(),
337
- ...(err.trim() ? { stderr: err.trim().slice(0, 4000) } : {}),
338
- ...(r.error ? { error: `命令执行失败:${String(r.error?.message || r.error)}` } : {}),
339
- };
340
- },
330
+ // v0.4.1 P1 修复:异步 spawn 而非 spawnSync——spawnSync 最长阻塞 600s,WebUI 下单个请求
331
+ // 即冻结整个 Node 进程(所有并发会话/权限确认/SSE 流全无响应)。异步执行不阻塞事件循环。
332
+ run: (/** @type {any} */ args, /** @type {any} */ ctx) =>
333
+ new Promise((resolve) => {
334
+ const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
335
+ const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', e.command] : ['-lc', e.command];
336
+ const timeoutMs = Math.min(Number(e.timeout) > 0 ? Number(e.timeout) : 120, 600) * 1000;
337
+ const child = spawn(shell, shellArgs, {
338
+ cwd: ctx.cwd,
339
+ env: { ...process.env, MINGDAO_TOOL_ARGS: JSON.stringify(args ?? {}) },
340
+ });
341
+ let out = '';
342
+ let err = '';
343
+ let done = false;
344
+ let timedOut = false;
345
+ const cap = (/** @type {any} */ s, /** @type {any} */ d) => {
346
+ const t = s + d;
347
+ return t.length > 20000 * 2 ? t.slice(-20000 * 2) : t;
348
+ };
349
+ const finish = (/** @type {any} */ result) => {
350
+ if (done) return;
351
+ done = true;
352
+ clearTimeout(timer);
353
+ resolve(result);
354
+ };
355
+ const timer = setTimeout(() => {
356
+ timedOut = true;
357
+ try { child.kill('SIGKILL'); } catch {}
358
+ }, timeoutMs);
359
+ child.stdout.on('data', (d) => { out = cap(out, d); });
360
+ child.stderr.on('data', (d) => { err = cap(err, d); });
361
+ child.on('error', (er) => finish({ ok: false, error: `命令执行失败:${String(er?.message || er)}` }));
362
+ child.on('close', (code) => {
363
+ const capped = out.length > 20000 ? out.slice(0, 20000) + `\n…[输出过长已截断,共 ${out.length} 字]` : out;
364
+ finish({
365
+ ok: timedOut ? false : (code ?? 0) === 0,
366
+ exitCode: timedOut ? null : (code ?? null),
367
+ output: (capped || '(无输出)').trim(),
368
+ ...(err.trim() ? { stderr: err.trim().slice(0, 4000) } : {}),
369
+ ...(timedOut ? { error: `命令执行超时(${Math.round(timeoutMs / 1000)}s),已终止` } : {}),
370
+ });
371
+ });
372
+ }),
341
373
  });
342
374
  mountedConfigTools.add(name);
343
375
  mounted.push(name);
package/src/web/app.js CHANGED
@@ -64,6 +64,7 @@ function initTips() {
64
64
  const perm = $('#permSel'); if (perm) attachTip(perm, perm.getAttribute('title'));
65
65
  const reas = $('#reasoningSel'); if (reas) attachTip(reas, reas.getAttribute('title'));
66
66
  const model = $('#modelSel'); if (model) attachTip(model, () => { const o = model.options[model.selectedIndex]; return (o && o.title) ? o.title : '切换模型'; });
67
+ const preset = $('#presetSel'); if (preset) attachTip(preset, () => { const o = preset.options[preset.selectedIndex]; return (o && o.title) ? o.title : '智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)'; });
67
68
  const at = $('#attachBtn'); if (at) attachTip(at, at.getAttribute('title'));
68
69
  }
69
70
  initTips();
@@ -148,6 +149,7 @@ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirM
148
149
  $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
149
150
  $('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
150
151
  const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
152
+ let presetData = []; // v0.4.0 Agent Preset:预设列表缓存(含 description/tools/permission,供下拉提示与选中反馈)
151
153
  let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
152
154
  let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
153
155
  let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
@@ -615,6 +617,23 @@ function applyReasoningUI(reasoning){
615
617
  sel.value = ['off','low','high','max'].includes(effort) ? effort : 'high';
616
618
  }
617
619
  $('#reasoningSel').onchange=()=>{ applyConfig({reasoningEffort:$('#reasoningSel').value}); };
620
+ // v0.4.0 Agent Preset:选中预设时给出「这是什么 / 覆盖了什么」的即时反馈(否则用户不知道各选项作用)
621
+ $('#presetSel').onchange=()=>{ presetPicked(); };
622
+ function presetPicked(){
623
+ const val=$('#presetSel')?.value||'';
624
+ const p=presetData.find((x)=>x.name===val);
625
+ if(!p){ renderBanner({ text: '已取消预设,恢复默认配置(不注入预设定制段/白名单/权限覆盖)。' }); return; } // 选回「无预设」= 退出预设模式
626
+ const over=[];
627
+ if(Array.isArray(p.tools)) over.push('工具白名单 '+p.tools.length+' 个');
628
+ if(p.permission) over.push('权限 '+p.permission);
629
+ if(p.model) over.push('模型 '+p.model);
630
+ if(p.maxRounds) over.push('maxRounds '+p.maxRounds);
631
+ if(p.maxOutputTokens) over.push('maxOutput '+p.maxOutputTokens);
632
+ if(p.temperature!==undefined) over.push('温度 '+p.temperature);
633
+ if(p.contextBudget) over.push('预算 '+p.contextBudget);
634
+ const summary=over.length?'(覆盖:'+over.join(' · ')+')':'';
635
+ renderBanner({ text: '🧩 已选预设「'+(p.label||p.name)+'」:'+(p.description||'(无描述)')+summary+'。随本次发送生效,其余设置保持不变。' });
636
+ }
618
637
  async function init(){
619
638
  try{
620
639
  const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
@@ -653,7 +672,8 @@ async function init(){
653
672
  try{
654
673
  // v0.4.0 Agent Preset:加载预设列表进下拉(项目 → 用户 → 内置)
655
674
  const pr=await fetch('/api/presets',{cache:'no-store'}).catch(()=>null); const pj=pr?await pr.json():{presets:[]};
656
- const psel=$('#presetSel'); if(psel&&pj.presets){ for(const p of pj.presets){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
675
+ presetData = Array.isArray(pj.presets) ? pj.presets : [];
676
+ const psel=$('#presetSel'); if(psel&&presetData.length){ for(const p of presetData){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
657
677
  }catch(e){}
658
678
  try{
659
679
  const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
@@ -930,7 +950,8 @@ async function refreshModelsCfg(){
930
950
  div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
931
951
  const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=async()=>{ const k=await uiPrompt('API Key('+c.name+'):', null, {hidden:true}); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else uiAlert(jj.error||'设置失败'); }); };
932
952
  const test=document.createElement('button'); test.textContent='测试'; test.style.cssText='padding:1px 8px;font-size:11px'; test.onclick=async()=>{ test.disabled=true; test.textContent='测试中…'; try{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'testCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ await uiAlert('✅ 连接成功('+jj.latencyMs+'ms):'+esc(jj.reply||'空回复')); } else { await uiAlert('✖ 连接失败:'+esc(jj.error||'未知错误')); } } finally { test.disabled=false; test.textContent='测试'; } };
933
- const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
953
+ const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; // v0.4.1:本地模型上下文窗口/最大输出可在设置里直接改(此前只能改 baseUrl+label,本地模型补 contextWindow 需手改 config.json)
954
+ const cw=await uiPrompt('上下文窗口 tokens(本地模型必填,如 131072;留空=不变)', c.contextWindow||''); if(cw===null) return; const mo=await uiPrompt('最大输出 tokens(可选,如 8192;留空=不变)', c.maxOutputTokens||''); if(mo===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l,contextWindow:Number(cw)||undefined,maxOutputTokens:Number(mo)||undefined})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
934
955
  const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'删除失败'); };
935
956
  div.appendChild(sk); div.appendChild(test); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
936
957
  }
@@ -332,7 +332,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
332
332
  <option value="readonly">只读</option>
333
333
  </select>
334
334
  <select id="modelSel" title="切换模型"></select>
335
- <select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(v0.4.0 契约化)"><option value="">预设…</option></select>
335
+ <select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)"><option value="">无预设</option></select>
336
336
  <select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
337
337
  <option value="off">关</option>
338
338
  <option value="low">低</option>
package/src/web/server.js CHANGED
@@ -257,7 +257,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
257
257
  if (h.includes(':')) {
258
258
  // IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
259
259
  if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
260
- return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
260
+ return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || h === '::' || h === '::1';
261
261
  }
262
262
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
263
263
  if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
@@ -421,10 +421,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
421
421
  let presetBlock = '';
422
422
  let chatCfg = cfg;
423
423
  if (typeof body.preset === 'string' && body.preset) {
424
- const { loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
424
+ const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
425
425
  chatPreset = loadPreset(taskDir, body.preset);
426
426
  if (chatPreset) {
427
427
  const over = presetConfigOverrides(chatPreset);
428
+ // P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
429
+ const permOv = presetPermissionOverride(chatPreset, cfg.permission ?? 'ask');
430
+ if (permOv.escalated) {
431
+ delete over.permission;
432
+ send({ type: 'banner', text: `⚠ 预设 "${chatPreset.name}" 声明 permission=${chatPreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。` });
433
+ } else if (chatPreset.permission !== undefined) {
434
+ over.permission = permOv.permission;
435
+ }
428
436
  chatCfg = { ...cfg, ...over, presetName: chatPreset.name };
429
437
  presetBlock = presetSystemBlock(chatPreset);
430
438
  } else {