mingdao-harness 0.6.9 → 0.6.11
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/README.en.md +1 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CODE-SIGNING.md +103 -1
- package/docs/CONFIG.md +73 -2
- package/package.json +1 -1
- package/presets/local-audit.json +3 -3
- package/src/agent.js +92 -104
- package/src/commands/ledger.js +93 -9
- package/src/cost-guard.js +78 -0
- package/src/help.js +7 -0
- package/src/ledger.js +406 -11
- package/src/presets.js +25 -4
- package/src/redact.js +9 -4
- package/src/tools/index.js +130 -14
- package/src/tools-flow.js +131 -0
- package/src/web/app.js +140 -17
- package/src/web/index.html +4 -3
- package/src/web/routes/domains/misc.js +61 -9
- package/src/web/server.js +116 -8
package/README.en.md
CHANGED
|
@@ -111,6 +111,7 @@ Commands are grouped here for evaluation; `mingdao --help` and `src/help.js` are
|
|
|
111
111
|
The full reference is **Chinese**; these are the entry points worth knowing:
|
|
112
112
|
|
|
113
113
|
- [`docs/CONFIG.md`](docs/CONFIG.md) — every `config.json` field: permission rules, sandbox, `config.net`, hooks, MCP, sync, cost guard.
|
|
114
|
+
- [`docs/CONFIG.en.md`](docs/CONFIG.en.md) — English configuration reference (verified against the code): permissions, sandbox & env filtering, egress gate and its limits, cost guard, WebUI exposure, audit/ledger, full field table.
|
|
114
115
|
- [`docs/PACK-API.md`](docs/PACK-API.md) — the Pack contract (manifest, contributions, constraint kinds, and which of them are **not** implemented).
|
|
115
116
|
- [`docs/PROVIDERS.md`](docs/PROVIDERS.md) — built-in providers and writing a custom OpenAI-compatible provider.
|
|
116
117
|
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — module map and data flow.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MingDao-Harness 架构设计
|
|
2
2
|
|
|
3
|
-
> 版本口径:v0.6.8。本文档覆盖 `src/` 全部 **
|
|
3
|
+
> 版本口径:v0.6.8。本文档覆盖 `src/` 全部 **89** 个模块(`find src -name '*.js' | wc -l` = 89),
|
|
4
4
|
> 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
|
|
5
5
|
>
|
|
6
6
|
> 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
|
|
@@ -76,9 +76,10 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
76
76
|
- `cost-guard.js` —— 费用护栏:按北京时间自然日累计真实费用,`warn` 线提醒、`limit` 线按 `action` 处理(`warn` / `block` / `downgrade` 到便宜模型)—— 长回合把在途费用并入今日已用,否则统计文件不变会烧穿日限。
|
|
77
77
|
- `batch.js` —— Batch API 半价通道:单轮批量任务(无工具、无流式)走 OpenAI 兼容批处理协议(`/files` → `/batches` → 轮询 → 下载结果,DeepSeek 风格结果端点回退 OpenAI)—— 端点不可用(404/405)必须明确报错告知网关不支持,**绝不静默假装成功**;计费按闲时全未命中 × 0.5,结果记入 `cachestats.js`(`batch: true`)。
|
|
78
78
|
|
|
79
|
-
### 2.3 工具与围栏(
|
|
79
|
+
### 2.3 工具与围栏(13)
|
|
80
80
|
|
|
81
81
|
- `tools/index.js` —— 工具注册表:模型可见的工具 Schema(OpenAI function-calling 格式)与执行分发器,含第三方 `registerTool` 与 `config.tools` 挂载 —— Schema 与 dispatch 分支必须成对增加。
|
|
82
|
+
- `tools-flow.js` —— 工具编排两端的判据(v0.6.11,审计 P1-1 拆分第三刀,纯函数):`visibleToolsFor()`(只读档 × 预设白名单 × 本会话已用工具快照 × MCP 只读标注 → 本轮**发给模型哪些工具**)、`serializeToolResult()` / `toolResultMessage()`(字符串原样 / 对象紧凑 JSON × 复用前缀 × 领域约束拒绝 × 按窗口截断 → 工具结果**怎么回填**)以及 `READONLY_TIER_SET`(`agent.js` 原样再导出,导入面不变)—— 打印/审计/账本/`messages.push` 等副作用仍留在 `agent.js`,判据只此一份,结构守卫见 `test/smoke.js` §136。
|
|
82
83
|
- `tools/fs-tools.js` —— 文件系统工具 read / write / edit / ls / glob / grep:统一返回 `{ok, output?|error?}` —— 工作空间围栏 `withinRoot()` 对悬空软链递归复检、深度超 16 层 fail-closed;写/编辑自动备份供 undo。
|
|
83
84
|
- `tools/bash.js` —— bash 工具:子进程执行、超时强杀、输出截断;沙箱三档 `off` / `readonly`(全盘只读 + /tmp tmpfs)/ `safe`(只读文件系统 + `--unshare-net` 断网 + 工作目录可写)—— 仅 Linux + bubblewrap 可用,缺失时降级为 `off` 并在结果中注明,不静默假装沙箱。
|
|
84
85
|
- `tools/fetch.js` —— HTTP 只读抓取工具:GET 任意 http(s) URL,返回文本(512KB 上限,正文截 20K)—— 只保留工具契约(参数校验、呈现口径、错误措辞),实际抓取整条走 `safeFetchText`。
|
package/docs/CODE-SIGNING.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
**当前构建为未签名**(官网已注明绕过方法);按本指南拿到证书后,把对应密钥配成
|
|
5
5
|
环境变量即可,无需改代码。
|
|
6
6
|
|
|
7
|
+
> 自动更新包的**来源签名**是**另一件事**(防"更新源被投毒",不是防 SmartScreen),见 §五:
|
|
8
|
+
> 代码已就位且判据 fail-closed,但**发布链路尚未接入**——默认档为 `warn-and-install`
|
|
9
|
+
> (照旧安装,但 UI/日志明确写出"本次更新未验证来源签名")。
|
|
10
|
+
|
|
7
11
|
---
|
|
8
12
|
|
|
9
13
|
## 一、Windows 代码签名(二选一)
|
|
@@ -85,8 +89,106 @@ macOS 作业的 `--mac dmg` 在检测到 `CSC_LINK` + Apple 凭证时自动完
|
|
|
85
89
|
概念:域名 OV 是 SSL/TLS 证书(绑定域名、EKU=Server Authentication、给 HTTPS 用);
|
|
86
90
|
代码签名 OV 绑定组织身份(EKU=Code Signing 1.3.6.1.5.5.7.3.3、给软件签名用),
|
|
87
91
|
常以 USB Key 交付,互不可替代。下单务必确认 EKU 为 Code Signing。
|
|
88
|
-
-
|
|
92
|
+
- **不想签**:现状即可用(官网已附绕过说明),仅多一步确认。
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 五、更新包的**来源签名**(ed25519,与代码签名是两件事)
|
|
97
|
+
|
|
98
|
+
> **先分清两件事**:上面一~四节是**代码签名**(证书 + Gatekeeper / SmartScreen,回答"这个 App 的
|
|
99
|
+
> 制作者是谁、系统要不要拦");本节是**更新包的来源签名**(回答"这个自动更新包是不是官网发的")。
|
|
100
|
+
> 两者互不替代:代码签名不能阻止应用从被投毒的更新源装一个**同样签了名**的包,来源签名也不负责
|
|
101
|
+
> 消除 SmartScreen 提示。
|
|
102
|
+
>
|
|
103
|
+
> **本轮状态(诚实标注)**:代码已就位并 fail-closed,但**发布链路尚未接入签名**——
|
|
104
|
+
> `desktop/gen-update-yml.mjs` 与服务器发布脚本都没有签名步骤,feed 里**没有** `.sig` 文件。
|
|
105
|
+
> 因此客户端当前实际走的是**默认档 `warn-and-install`**:照旧安装,但在日志与 UI 里明确写出
|
|
106
|
+
> 「**本次更新未验证来源签名**」。要让"没签名就拒绝"生效,发行方需要:① 按下面步骤接入签名;
|
|
107
|
+
> ② 在客户端设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1`(或改成默认值)。
|
|
108
|
+
|
|
109
|
+
### 5.1 为什么不是"再算一次 sha512"
|
|
110
|
+
|
|
111
|
+
`electron-updater` 自带的 sha512 校验,比的是「下载到的字节」与「**同一份 feed 提供的** `latest*.yml`
|
|
112
|
+
里的 sha512」——清单与包**同源**。控制 feed 的一方(DNS / 主机 / CDN / 证书任一被攻破)可以同时换掉包
|
|
113
|
+
与清单里的 sha512,校验照样通过,随后 `quitAndInstall()` 执行攻击者的安装包。
|
|
114
|
+
|
|
115
|
+
这不是推测,是本仓探针(未入库:`/tmp/probe-update-feed.mjs`,本机 HTTP 投毒 feed + 桩 `electron-updater`,
|
|
116
|
+
跑的是**真实的** `desktop/main.js`)实测过的:投毒 feed + 攻击者自己算的 sha512 → 下载 → 校验 MATCH →
|
|
117
|
+
安装被调用;只改字节不改清单(传输损坏)→ sha512 mismatch → 不安装。**即 sha512 只防传输损坏,不防换源。**
|
|
118
|
+
|
|
119
|
+
来源签名补的正是后者:签名用**只有发行方持有**的私钥生成,客户端只内置**公钥**。攻击者控制了 feed
|
|
120
|
+
也造不出签名(他自己签的包会被内置公钥判无效)。
|
|
121
|
+
|
|
122
|
+
### 5.2 密钥托管(私钥留在离线机 / CI secret)
|
|
123
|
+
|
|
124
|
+
| 项 | 约定 |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| 算法 | ed25519(`node:crypto`,零依赖;与 `src/ledger.js` 的账本来源签名同一套约定) |
|
|
127
|
+
| 私钥位置 | **离线机**或 CI secret(如 `MINGDAO_UPDATE_SIGN_KEY` 指向的文件)。**绝不入库、绝不打印、绝不进聊天/工单/截图**(`--keygen` 只打印公钥) |
|
|
128
|
+
| 私钥文件权限 | `600`(`--keygen` 自动设置;Windows 无 POSIX 权限位,用目录权限隔离) |
|
|
129
|
+
| 公钥位置 | 贴进 `desktop/update-verify.js` 的 `UPDATE_PUBLIC_KEY_PEM` 常量(**内置常量**,运行期不从网络取——从网络取公钥等于没 pin) |
|
|
130
|
+
| 密钥轮换 | 换密钥 = 所有旧签名失效。`--keygen` **拒绝覆盖**已存在的密钥文件(必须先手工移走),避免"悄悄换了信任根" |
|
|
131
|
+
| 本轮内置的那把公钥 | `keyId=d1e7bef80ca5d508`。它对应的私钥在本轮**未保留**(生成后即弃,从未写盘/打印)——所以"验签通过"这条路径在当前发布链路下不会被走到;它保证的是:**任何别人签的包一律验不过** |
|
|
132
|
+
|
|
133
|
+
### 5.3 发布步骤(发行方启用签名时)
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# ① 生成密钥对(在离线机 / 受控环境;私钥写仓库之外,脚本会拒绝写进仓库)
|
|
137
|
+
node scripts/update-sign.mjs --keygen --out ~/.mingdao/update-signing
|
|
138
|
+
# → 把打印出来的**公钥** PEM 贴进 desktop/update-verify.js 的 UPDATE_PUBLIC_KEY_PEM
|
|
139
|
+
|
|
140
|
+
# ② 每次发版:对每个平台的安装包签名(产出 <包>.sig)
|
|
141
|
+
node scripts/update-sign.mjs --sign desktop/dist/mingdao-setup-<ver>-x64.exe
|
|
142
|
+
node scripts/update-sign.mjs --sign desktop/dist/mingdao-<ver>-x86_64.AppImage
|
|
143
|
+
# → 打印的 sha512 必须与 latest*.yml 里的 sha512 **逐字一致**(不一致说明签错了文件)
|
|
144
|
+
|
|
145
|
+
# ③ 发布前自检(与客户端**同一实现**,避免"发布侧说通过、客户端说无效")
|
|
146
|
+
node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe --pub update-signing-pub.pem
|
|
147
|
+
|
|
148
|
+
# ④ 上传:安装包 + 它的 .sig **一起**放进 feed 目录(官网 /updates/,与 latest*.yml 同级)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
判据与文案在 `desktop/update-verify.js`(纯函数,`test/smoke.js` §135 逐条钉边界):
|
|
152
|
+
|
|
153
|
+
| 情形 | 决策 | 行为 |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| 有签名 + 验签通过 | `install` | 提示"来源签名校验通过(ed25519 · keyId=…)",正常安装 |
|
|
156
|
+
| **有签名 + 验签失败 / 拿不到证据** | `reject` | **一律拒绝**:UI"更新包来源不可信,已拒绝"+ 引导官网手动下载;并**关闭 `autoInstallOnAppQuit`**(否则退出应用时照样装) |
|
|
157
|
+
| 无签名 + 默认 | `warn-and-install` | 照旧安装,但日志与 UI 都写明"**本次更新未验证来源签名**"、并说明当前发布链路没有签名步骤 |
|
|
158
|
+
| 无签名 + `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` | `reject` | fail-closed:发行方/高安全用户的开关 |
|
|
159
|
+
|
|
160
|
+
**为什么不默认 reject**:当前发布链路还没有签名,默认 reject 等于一夜之间掐死所有存量用户的自动更新。
|
|
161
|
+
威胁模型里"feed 被投毒"是低概率高影响,"更新永远装不上"是必然发生——所以默认放行但**如实告知**,
|
|
162
|
+
把 fail-closed 开关交给发行方。发布链路接入签名后,把 `decideUpdatePolicy` 的默认档改成 `reject`
|
|
163
|
+
即可(一行,判据与文案不用动)。
|
|
164
|
+
|
|
165
|
+
### 5.4 失败处置
|
|
166
|
+
|
|
167
|
+
| 现象 | 原因 | 处置 |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| 用户看到"**更新包来源不可信,已拒绝**" | 签名无效(包被改 / 由别的密钥签发)或 `REQUIRE=1` 而没有签名 | 让用户到官网手动下载;发行方核对 `latest*.yml` 里的 sha512 与 `--sign` 打印的 sha512 是否一致、feed 里是否放上了对应的 `.sig` |
|
|
170
|
+
| 用户看到"更新已就绪(**未验证来源签名**)" | 默认档 + feed 没有 `.sig` | 属预期(见 5.1 的本轮状态);要消除它就得接入签名并设 `REQUIRE=1` |
|
|
171
|
+
| 明明签了名,客户端还是说"未验证来源签名" | `.sig` 没上传 / 文件名不是 `<安装包名>.sig` / feed 目录与 `UPDATE_FEED_BASE` 不一致 | 核对 feed 目录里 `<包名>.sig` 是否存在;改了 `electron-builder.yml` 的 `publish.url` 时,`desktop/update-verify.js` 的 `UPDATE_FEED_BASE` 必须一起改 |
|
|
172
|
+
| 换了密钥后**所有**客户端都拒绝 | 正常且期望:内置公钥还是旧的那把 | 用 `--keygen` 生成新密钥 → 替换 `UPDATE_PUBLIC_KEY_PEM` → 发一次带签名的新版本(旧版本客户端无法验新密钥,这是非对称 pin 的固有代价) |
|
|
173
|
+
| 私钥文件/口令疑似泄露 | —— | **立即轮换**(`--keygen` 新密钥 + 替换公钥常量),并公告旧版本客户端的验签行为;仓库里若出现过私钥,视为已泄露(`git log` 里删不掉) |
|
|
174
|
+
|
|
175
|
+
### 5.5 边界(不在本机制范围内)
|
|
176
|
+
|
|
177
|
+
- **签名缺失时的降级无法区分**:控制 feed 的攻击者可以**删掉** `.sig`,把这次更新降级成"无签名"。
|
|
178
|
+
默认档下这仍然会走到 `warn-and-install`。要真正关闭它,必须让发布链路**每次都签**并且客户端
|
|
179
|
+
`REQUIRE=1`。这是"默认不掐死存量更新"这一取舍的必然后果,已写进源码注释与登记文档。
|
|
180
|
+
- **签名载体**:客户端按 ① `latest*.yml` 内嵌 `signature:` 字段 → ② 本机旁车 `<包>.sig` →
|
|
181
|
+
③ feed 目录 `<包名>.sig`(HTTPS 取公开数据)的顺序取材。**当前实测过的是旁车 `.sig`**;
|
|
182
|
+
内嵌字段要依赖 `electron-updater` 把未知 yml 字段透传到 `update-downloaded` 的 `info`
|
|
183
|
+
(本仓未对真实库验证),故发布流程以旁车为准。
|
|
184
|
+
- `MINGDAO_UPDATE_PUBKEY_PEM` + `MINGDAO_UPDATE_ALLOW_PUBKEY_OVERRIDE=1` 是**显式双开关**的开发/测试
|
|
185
|
+
覆盖(两个都要给才生效)。**发布构建不得设置**;只设前者不会替换内置公钥。
|
|
186
|
+
- 不含 macOS/Windows 的**代码签名**与公证(见第一~四节),也不含"官网下载页手动安装包"的来源校验
|
|
187
|
+
(用户手动下载时走的是浏览器 TLS + 系统 SmartScreen/Gatekeeper)。
|
|
188
|
+
- 不含密钥吊销 / 多密钥并存验签(换密钥即全量失效,见上表)。
|
|
189
|
+
- 私钥与构建机同权时,本层不防"能改构建产物的人"——它防的是**发布源被换**。
|
|
89
190
|
|
|
90
191
|
---
|
|
91
192
|
|
|
92
193
|
*配置文件位置:`desktop/electron-builder.yml`(azureSignOptions 注释)、`.github/workflows/desktop.yml`(env 段)。*
|
|
194
|
+
*来源签名实现:`desktop/update-verify.js`(判据/文案单源)、`desktop/main.js`(门禁接线)、`scripts/update-sign.mjs`(发布侧工具)、`test/smoke.js` §135、`test/mutate/batch21-update-sign.mjs`。*
|
package/docs/CONFIG.md
CHANGED
|
@@ -111,6 +111,36 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
|
|
|
111
111
|
> 那里"一次误按 y 就绕过自己写的禁令"是不可接受的;
|
|
112
112
|
> 交互式日常使用保持默认即可——每次放行都是一次明确的、点名规则的确认。
|
|
113
113
|
|
|
114
|
+
### 权限优先级与预设(v0.6.14)
|
|
115
|
+
|
|
116
|
+
口径:**显式选择 > 预设建议(`recommendedPermission`)> config.json**。
|
|
117
|
+
(这句说的是"档位意图从哪来"的优先序;落到判定上,`recommendedPermission` **只是建议**——不参与判定、
|
|
118
|
+
不自动改档。真正会改档的只有两处:你的显式选择,以及老/第三方预设声明的 `permission`(受反提权约束)。)
|
|
119
|
+
|
|
120
|
+
| 层级 | 从哪来 | 性质 |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| 显式选择 | CLI `mingdao run "…" --permission auto\|ask\|readonly`、REPL `/mode`、WebUI「权限模式」下拉(随本次请求发出) | **覆盖**:本回合的终值,任何预设都压不过它 |
|
|
123
|
+
| 预设建议 | 预设 JSON 的 `recommendedPermission`(内置 `local-audit` 即 `readonly`);`GET /api/presets` 会透出该字段 | **只是建议**:不参与权限判定、不静默改档;想让建议生效,就在上面那一层显式选它 |
|
|
124
|
+
| config.json | `permission` 字段(支持 `{mode, allow, deny}` 对象形态) | **兜底**:没有显式选择、预设也没声明 `permission` 时才用它 |
|
|
125
|
+
|
|
126
|
+
三条要点(别再踩):
|
|
127
|
+
|
|
128
|
+
- **预设的 `tools` 白名单才是只读的硬约束。** 内置 `local-audit` 的白名单里没有 `write`/`edit`,
|
|
129
|
+
写工具根本不会出现在模型可见的工具表里——这比任何权限档都硬(权限档只决定"要不要逐次询问")。
|
|
130
|
+
所以"要一个只读预设"的正确做法是**收白名单**,不是把会话钉死在 `readonly`。
|
|
131
|
+
- **`permission` 字段是覆盖。** 预设声明它会改本回合的档位(更宽松的方向被反提权拦掉:
|
|
132
|
+
`readonly→auto` 一律拒绝并只发一条 ⚠ banner)。**内置预设不得声明 `permission`**——它唯一的
|
|
133
|
+
实际效果是"沉默覆盖用户的选择":负责人实测的坑就是界面上选了「自动」,勾上内置审计预设后仍按
|
|
134
|
+
`readonly` 跑,于是每调用一次非只读工具都弹「只读模式将拦截 task,是否本次放行?」。
|
|
135
|
+
第三方/老预设仍可声明(兼容),但同样受反提权约束,且要自己承担"覆盖用户选择"的后果。
|
|
136
|
+
- **`recommendedPermission` 只是建议。** 它是预设对"我适合跑在哪个档"的表达,只做展示/透出,
|
|
137
|
+
不写进任何权限判定;权限档以你的显式选择为准,其次才是 config.json 里的默认值。
|
|
138
|
+
内置 `local-audit` 用它表达"建议只读",只读本身则由 tools 白名单落到实处。
|
|
139
|
+
|
|
140
|
+
> 另一个常被名字误导的点:内置 `local-audit`(「只读代码审计(本地模型)」)**不启用审计日志/账本**。
|
|
141
|
+
> 工具调用审计是 `config.audit` 与 `mingdao audit` / `mingdao ledger` 的事,与这个预设无关——
|
|
142
|
+
> 别把它当合规审计开关。
|
|
143
|
+
|
|
114
144
|
## 项目记忆(自动写入项目目录,默认已自忽略)
|
|
115
145
|
|
|
116
146
|
会话收尾时会从对话里提炼几条长期有效的结论,写进**当前项目**目录:
|
|
@@ -189,7 +219,10 @@ mingdao net report --since 7d --json # 支持 7d / 24h / 90m
|
|
|
189
219
|
}
|
|
190
220
|
```
|
|
191
221
|
|
|
192
|
-
- `PreToolUse
|
|
222
|
+
- `PreToolUse`:工具执行前调用;**只有** stdout 回 `{"decision":"block","reason":"…"}` 才阻止执行
|
|
223
|
+
(非 JSON 的非空输出按 fail-closed 也阻止;输出为空、或 JSON 里 `decision` 不是 `block`(如 `approve`)则放行)。
|
|
224
|
+
> 勘误(v0.6.11):此处此前写作"命令输出非空即阻止执行",与实现不符——照它写合规钩子会以为
|
|
225
|
+
> "随便打印点什么就能拦住",实际必须回那个 JSON 结构。
|
|
193
226
|
- `PostToolUse`:执行后调用(审计/日志);
|
|
194
227
|
- 协议:stdin 收 JSON(工具名/参数),stdout 回 JSON;`matcher` 支持 `|` 分隔与 `*` 通配;
|
|
195
228
|
- ⚠ **hooks 命令以 `shell: true` 执行——配置即代码执行**:命令会在每次工具调用时运行,请只填自己完全信任的命令(例如不直接填 `curl <不可信地址>`)。
|
|
@@ -233,7 +266,10 @@ mingdao net report --since 7d --json # 支持 7d / 24h / 90m
|
|
|
233
266
|
同机的其它进程/其它用户可以直接访问 `/api/*`,多用户机器与共享 CI 上请启用令牌;
|
|
234
267
|
- 绑定 `0.0.0.0`/局域网地址时**强制令牌认证**:未配置则每次启动随机生成并打印
|
|
235
268
|
`http://<地址>:<端口>/?token=…` 访问链接;固定令牌(优先级从高到低):
|
|
236
|
-
|
|
269
|
+
`mingdao web --auth-token <令牌>`(或 `--auth-token=-` 从 stdin 读)、环境变量 `MINGDAO_WEB_TOKEN`、`web.token`
|
|
270
|
+
——即**命令行 > 环境变量 > 配置**(`src/commands/skill.js` 的 `--auth-token` 分支 > `MINGDAO_WEB_TOKEN` > `config.web.token`)。
|
|
271
|
+
> 勘误(v0.6.11):此处此前把顺序写反成"环境变量、配置、命令行"。文档审计(F-M2 同批)核对了代码,
|
|
272
|
+
> 实际是"命令行优先"——照旧文档配置的人会以为环境变量最高,从而留下一个自己没意识到的固定令牌。
|
|
237
273
|
**命令行字面量会进入 argv 与 shell 历史(`ps` 可见)**,故用它时会给出告警;
|
|
238
274
|
- 令牌同时接受 URL `?token=`、请求头 `X-MingDao-Token` 或 `Authorization: Bearer`;
|
|
239
275
|
- 服务端校验 `Host` 头必须等于回环名或绑定地址(防 DNS rebinding),代理场景会 403 属预期。
|
|
@@ -404,6 +440,41 @@ export async function createProvider(cfg) {
|
|
|
404
440
|
- 调度器为**单守护进程**:`mingdao schedule daemon [status|stop]` 查看/停止;有任务时任意
|
|
405
441
|
schedule 命令自动拉起,无任务自动退出(每任务一个 sleeper 进程的旧方案仅作兜底)。
|
|
406
442
|
|
|
443
|
+
## 账本来源签名(v0.6.11:`mingdao ledger --sign-key`)
|
|
444
|
+
|
|
445
|
+
哈希链 + 封条证明的是「这份账本自写入后没被改动、尾部没被截断」,但它们的全部输入都来自
|
|
446
|
+
账本自身、算法(sha256)是公开的——**改内容 + 逐行重算 prev + 改写封条**之后照样逐项吻合
|
|
447
|
+
(实测复现,见 `docs/internal/AUDIT-v0.6.1-第三方报告登记.md` §3.45)。来源签名补上的是另一半:
|
|
448
|
+
「这条账本是不是**持有本机那把密钥**的一方写的」。
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
mingdao ledger --sign-key # 查看密钥状态(只显示公钥与指纹,绝不回显私钥)
|
|
452
|
+
mingdao ledger --sign-key --generate # 生成密钥 → <home>/ledger-key.json(权限 600)
|
|
453
|
+
mingdao ledger --sign-key --generate --force # 覆盖已有密钥(会让旧账本变成"另一把密钥签发",先备份)
|
|
454
|
+
mingdao ledger verify <runId> # 链/封条 + 来源签名(默认用本机密钥验签)
|
|
455
|
+
mingdao ledger verify <runId> --key <文件> # 用指定公钥验签(本模块密钥文件,或 PEM/SPKI 公钥)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
- **算法 ed25519**(`node:crypto` 原生,零新增依赖):非对称,公钥可以交给第三方复核,
|
|
459
|
+
对方能验证「由那把密钥签发且未被改动」,但**不能**补签一份新账本。HMAC 做不到这一点——
|
|
460
|
+
它的验证密钥就是签名密钥,把审查权交出去等于把伪造能力一起交出去。
|
|
461
|
+
- **密钥文件** `<home>/ledger-key.json`:权限 600、原子写、**绝不进 `config.json`、凭证库
|
|
462
|
+
(`credentials.json`)与仓库**。不并进凭证库是因为 `key remove` / `key import` 会全量重写它,
|
|
463
|
+
顺手一次就会把签名密钥清掉、让全部历史账本变成"另一把密钥签发"。
|
|
464
|
+
- **默认开启**:新账本在首次收尾时若本机还没有密钥会**自动生成**并签名(可选签名在合规上等于
|
|
465
|
+
没有签名)。封条里带 `sig{alg,keyId,value}`,`run.end` 链内带 `sigKey`(公钥指纹)。
|
|
466
|
+
- **`ledger verify` 的三种结论**(前两者退出码 0,第三者非 0):
|
|
467
|
+
|
|
468
|
+
| 结论 | 含义 | 退出码 |
|
|
469
|
+
| --- | --- | --- |
|
|
470
|
+
| 链完整 + 签名有效 | 由持有该密钥的一方写入,且内容/封条自签名后未被改动 | 0 |
|
|
471
|
+
| 链完整但无签名 | 写于启用签名之前的老账本(或签名证据被整体抹除,两者本项无法区分)——只证明哈希链完整 | 0 |
|
|
472
|
+
| 链完整但签名无效 | 内容被改写后重算过链,或换了一把密钥签发(含"本条账本来自另一台机器") | 非 0 |
|
|
473
|
+
|
|
474
|
+
另有第四态「签名无法校验」(本机/`--key` 没有对应公钥)同样非 0——**不能确认来源**不等于通过。
|
|
475
|
+
- **诚实边界**:私钥与账本同机同权限,能改账本的对手通常也能读私钥,本层不防同权限的本机对手;
|
|
476
|
+
不含可信时间戳,不等同于审计级不可否认;私钥丢失后历史账本只能报"由另一把密钥签发"。
|
|
477
|
+
|
|
407
478
|
## 月度费用报告
|
|
408
479
|
|
|
409
480
|
`mingdao cost` 控制台速览;`mingdao cost report [YYYY-MM|all]` 导出 Markdown 报告
|
package/package.json
CHANGED
package/presets/local-audit.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-audit",
|
|
3
|
-
"label": "
|
|
4
|
-
"description": "面向本地/资源受限模型(如 131k 窗口 q8
|
|
3
|
+
"label": "只读代码审计(本地模型)",
|
|
4
|
+
"description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读代码审计预设:只读工具集 + 保守参数。两点先说清:① 本预设**不启用审计日志/账本**——那是 config.audit 与 `mingdao ledger`,与本预设无关,别把它当合规审计开关;② 本预设**不覆盖你的权限档**——只读效果由 tools 白名单保证(里面没有 write/edit,模型根本看不到写工具),权限档始终以你的显式选择为准(recommendedPermission=readonly 只是建议,不参与判定)。",
|
|
5
5
|
"systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。对于大仓库可分模块派发只读子代理并行审查(task 工具传 readOnly:true,子代理同样只读:read/ls/glob/grep/skill)。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
|
|
6
6
|
"tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo", "task"],
|
|
7
|
-
"
|
|
7
|
+
"recommendedPermission": "readonly",
|
|
8
8
|
"maxRounds": 4,
|
|
9
9
|
"maxOutputTokens": 4096
|
|
10
10
|
}
|
package/src/agent.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
|
|
3
3
|
// 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
|
|
4
4
|
|
|
5
|
-
import { trimMessages,
|
|
5
|
+
import { trimMessages, messageTokens, approxTokens } from './context.js';
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
|
-
|
|
7
|
+
// 审计 M-1 §2.1 第 4 条(Pack 反向提权):`makeToolCtx` 把内核 ctx 剪成"工具面向的 ctx"并冻结。
|
|
8
|
+
// 两个对象从此分开:内核 ctx(makeCtx() 的产物)只在循环内用;工具只拿到 makeToolCtx() 的白名单对象。
|
|
9
|
+
import { buildToolSchemas, dispatch, makeToolCtx } from './tools/index.js';
|
|
8
10
|
import { DEFAULT_MODEL, modelPreset } from './models.js';
|
|
9
11
|
import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
|
|
10
12
|
import { makeTokenCounter } from './tokenizer.js';
|
|
@@ -13,7 +15,7 @@ import { createIO, style, C } from './ui.js';
|
|
|
13
15
|
import { subagentModel } from './routing.js';
|
|
14
16
|
import { writeAudit } from './audit.js';
|
|
15
17
|
import { redactSecrets } from './redact.js';
|
|
16
|
-
import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
|
|
18
|
+
import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
|
|
17
19
|
import { recordCacheStats, packDailyCost } from './cachestats.js';
|
|
18
20
|
import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
|
|
19
21
|
import { resolveProviderConfig, createProvider } from './providers/index.js';
|
|
@@ -21,6 +23,10 @@ import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOu
|
|
|
21
23
|
import { createLedger, newRunId } from './ledger.js';
|
|
22
24
|
import { registerEgressSink } from './net-guard.js';
|
|
23
25
|
import { getActivePackContext } from './packs.js';
|
|
26
|
+
// v0.6.11(审计 P1-1 拆分第三刀):工具编排两端的判据单源在 tools-flow.js——
|
|
27
|
+
// ① 本轮可见哪些工具(只读档 × 预设白名单 × 已用工具快照 × MCP 只读标注);
|
|
28
|
+
// ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断)。
|
|
29
|
+
import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage } from './tools-flow.js';
|
|
24
30
|
|
|
25
31
|
const MAX_STEPS = 24;
|
|
26
32
|
// 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
|
|
@@ -28,10 +34,10 @@ const MAX_STEPS = 24;
|
|
|
28
34
|
// 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
|
|
29
35
|
const SUBAGENT_MAX_STEPS = 24;
|
|
30
36
|
|
|
31
|
-
// 只读档工具集(省钱 B1
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
export
|
|
37
|
+
// 只读档工具集(省钱 B1 的「只读阶段」)——**单一来源已移到 `tools-flow.js`**(v0.6.11 / P1-1 第三刀):
|
|
38
|
+
// 它与「本轮可见哪些工具」的判据是同一条规则,放在一起才能保证"改一处就够"。
|
|
39
|
+
// 这里只做**再导出**:bench/smoke 既有的 `from './agent.js'` 导入面与集合内容都不变(行为零变化)。
|
|
40
|
+
export { READONLY_TIER_SET };
|
|
35
41
|
|
|
36
42
|
// ---------------------------------------------------------------------------
|
|
37
43
|
// 写意图 / 疑问句 / 未达成意图的判定(v0.6.3)
|
|
@@ -178,24 +184,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
178
184
|
// v0.4.0 Agent Preset:cfg.presetTools 白名单恒生效(在只读档过滤之后收紧——预设只减不增)。
|
|
179
185
|
const presetToolSet = Array.isArray(cfg.presetTools) ? new Set(cfg.presetTools.map(String)) : null;
|
|
180
186
|
const activePresetName = String(cfg.presetName || ''); // 白名单拦截提示用
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!readOnlyPhase) return schemas;
|
|
191
|
-
return schemas.filter((/** @type {any} */ t) => {
|
|
192
|
-
const n = t?.function?.name;
|
|
193
|
-
if (!n) return true;
|
|
194
|
-
if (READONLY_TIER_SET.has(n) || strippedSet.has(n)) return true;
|
|
195
|
-
if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
|
|
196
|
-
return false;
|
|
187
|
+
// 工具可见性判据(v0.6.11 / P1-1 第三刀):白名单过滤 + 只读档过滤的实现已单源到 `tools-flow.js`。
|
|
188
|
+
// 这里只负责把"当前拿得到的东西"递进去(判据本身不在本文件里;结构守卫见 smoke §136)。
|
|
189
|
+
const toolsFor = (/** @type {boolean} */ readOnlyPhase, /** @type {Set<string>} */ usedNames) =>
|
|
190
|
+
visibleToolsFor(buildToolSchemas(usedNames, mcpSchemas()), {
|
|
191
|
+
readOnlyPhase,
|
|
192
|
+
usedNames,
|
|
193
|
+
presetToolSet,
|
|
194
|
+
// MCP 的只读标注按名查询;`isReadonly` 是 mcp 对象上的方法(内部用 this),必须包一层
|
|
195
|
+
isMcpReadonly: mcp ? (/** @type {string} */ n) => mcp.isReadonly(n) : null,
|
|
197
196
|
});
|
|
198
|
-
};
|
|
199
197
|
|
|
200
198
|
// 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
|
|
201
199
|
async function spawnTask(/** @type {any} */ prompt, { description = '', readOnly = false } = {}) {
|
|
@@ -487,7 +485,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
487
485
|
const turnStrippedSet = new Set(usedToolNames);
|
|
488
486
|
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
489
487
|
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
488
|
+
// ⚠ 两个 ctx,别混:
|
|
489
|
+
// · `ctx`(内核 ctx)——本轮内核自己用(循环里的权限判定走闭包里的 permission 本体);
|
|
490
|
+
// 它**不裁剪、不冻结**,且**不交给任何工具**。
|
|
491
|
+
// · `toolCtx`(工具 ctx)——由 makeToolCtx(ctx) 派生:白名单字段 + Object.freeze,
|
|
492
|
+
// 没有 provider / io / 原始 permission 对象;只有这一份会经 dispatch 交到工具手上
|
|
493
|
+
// (dispatch 内还会按"内置 / 第三方"分档再收紧一次,见 src/tools/index.js 的注释)。
|
|
490
494
|
const ctx = makeCtx();
|
|
495
|
+
const toolCtx = makeToolCtx(ctx);
|
|
491
496
|
/**
|
|
492
497
|
* v0.5.0 A3 ③:输出前领域约束(正文禁用措辞)。
|
|
493
498
|
* 无约束时零开销(constraints.active=false 直接返回原文);有约束时按 action 处理:
|
|
@@ -677,15 +682,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
677
682
|
let promptTokens = 0;
|
|
678
683
|
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
679
684
|
const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
+
// 判据本身抽到 cost-guard.js 的 preflightBlockMessage(v0.6.11 / P1-1 第一刀):
|
|
686
|
+
// 它能被单测逐条钉边界,而 runTurn 只负责"拿到数字 → 问判据 → 该拦就拦"。
|
|
687
|
+
// 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
|
|
688
|
+
const msg = preflightBlockMessage(g.dailyLimitYuan, usedTodayWithInflight(), worst);
|
|
689
|
+
if (msg) {
|
|
685
690
|
stripOrphanCalls();
|
|
686
691
|
return {
|
|
687
692
|
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
688
|
-
note:
|
|
693
|
+
note: msg,
|
|
689
694
|
durationMs: Date.now() - startedAt, perf: perf(),
|
|
690
695
|
};
|
|
691
696
|
}
|
|
@@ -695,77 +700,54 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
695
700
|
// 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
|
|
696
701
|
// downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
|
|
697
702
|
if (cfg.costGuard) {
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
703
|
+
// 判据全部抽到 cost-guard.js 的 roundGuardAction()(v0.6.11 / P1-1 第二刀):
|
|
704
|
+
// 这里只负责"问决策 → 按决策执行副作用"(打印、切换模型、提前返回)。
|
|
705
|
+
// 之所以值得抽:这段里有两处**实测复现过的计费缺陷**(降级后又静默继续、
|
|
706
|
+
// 在途费用不参与决策),埋在循环里时只能靠端到端验证,现在四个动作可以被逐条钉死。
|
|
707
|
+
const d = roundGuardAction(
|
|
708
|
+
checkCostGuard(activeModel),
|
|
709
|
+
costGuardConfig(),
|
|
710
|
+
usedTodayWithInflight(),
|
|
711
|
+
downgraded,
|
|
712
|
+
activeModel,
|
|
713
|
+
DEFAULT_MODEL
|
|
714
|
+
);
|
|
715
|
+
if (d.action === 'block') {
|
|
716
|
+
stripOrphanCalls();
|
|
717
|
+
return {
|
|
718
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
719
|
+
note: d.message, durationMs: Date.now() - startedAt, perf: perf(),
|
|
720
|
+
};
|
|
717
721
|
}
|
|
718
|
-
if (
|
|
719
|
-
|
|
722
|
+
if (d.action === 'already-cheapest') {
|
|
723
|
+
stripOrphanCalls();
|
|
724
|
+
return {
|
|
725
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
726
|
+
note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
|
|
727
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (d.action === 'try-downgrade') {
|
|
731
|
+
const target = String(d.model);
|
|
732
|
+
// MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
|
|
733
|
+
// 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
|
|
734
|
+
const curPc = resolveProviderConfig(cfg, activeModel);
|
|
735
|
+
const dgPc = resolveProviderConfig(cfg, target);
|
|
736
|
+
// Key 归属服务商(provider 级),同服务商即天然共享同一 Key,无需再查 apiKey
|
|
737
|
+
if (dgPc && dgPc.name === curPc.name) {
|
|
738
|
+
activeModel = target;
|
|
739
|
+
downgraded = true;
|
|
740
|
+
io.print(style(d.message, C.yellow));
|
|
741
|
+
} else {
|
|
720
742
|
stripOrphanCalls();
|
|
721
743
|
return {
|
|
722
|
-
text: null,
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
steps,
|
|
726
|
-
finish,
|
|
727
|
-
truncated: false,
|
|
728
|
-
aborted: false,
|
|
729
|
-
note: guard.message,
|
|
730
|
-
durationMs: Date.now() - startedAt,
|
|
731
|
-
perf: perf(),
|
|
744
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
745
|
+
note: `费用护栏想降级到 ${target},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
|
|
746
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
732
747
|
};
|
|
733
748
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
// MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
|
|
737
|
-
// 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
|
|
738
|
-
const curPc = resolveProviderConfig(cfg, activeModel);
|
|
739
|
-
const dgPc = resolveProviderConfig(cfg, guard.downgradeModel);
|
|
740
|
-
// Key 归属服务商(provider 级),同服务商即天然共享同一 Key,无需再查 apiKey
|
|
741
|
-
if (dgPc && dgPc.name === curPc.name) {
|
|
742
|
-
activeModel = guard.downgradeModel;
|
|
743
|
-
downgraded = true;
|
|
744
|
-
io.print(style(guard.message, C.yellow));
|
|
745
|
-
} else {
|
|
746
|
-
stripOrphanCalls();
|
|
747
|
-
return {
|
|
748
|
-
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
749
|
-
note: `费用护栏想降级到 ${guard.downgradeModel},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
|
|
750
|
-
durationMs: Date.now() - startedAt, perf: perf(),
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
} else {
|
|
754
|
-
// 已经在降级目标模型上:无法再降,按 block 处理。
|
|
755
|
-
// v0.6.5(审计 BUG-023/035,实测复现):此前的外层条件是 `guard.downgrade && !downgraded`,
|
|
756
|
-
// 于是**本回合刚降过一次**之后(downgraded=true),下一步会落到「两个分支都不进」——
|
|
757
|
-
// 既不切换也不再拦,静默继续用 flash 发请求继续计费。判据必须是「超限 + 已在最便宜模型」,
|
|
758
|
-
// 与「一开局就是 flash」那条路径完全同源(那条本来就会拦,两条路径此前结论相反)。
|
|
759
|
-
stripOrphanCalls();
|
|
760
|
-
return {
|
|
761
|
-
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
762
|
-
note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
|
|
763
|
-
durationMs: Date.now() - startedAt, perf: perf(),
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
} else {
|
|
767
|
-
io.print(style(guard.message, C.yellow));
|
|
768
|
-
}
|
|
749
|
+
} else if (d.action === 'warn') {
|
|
750
|
+
io.print(style(d.message, C.yellow));
|
|
769
751
|
}
|
|
770
752
|
}
|
|
771
753
|
|
|
@@ -1068,7 +1050,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1068
1050
|
if (!mcp) throw new Error('MCP 工具未启用');
|
|
1069
1051
|
result = await mcp.call(prep.name, prep.args);
|
|
1070
1052
|
} else {
|
|
1071
|
-
|
|
1053
|
+
// 交出去的是 `toolCtx`(工具面向:白名单 + 冻结),**不是** `ctx`(内核那个)。
|
|
1054
|
+
// 内置工具与 Pack / config.tools 工具走同一入口,因此在这一行之后没有任何工具
|
|
1055
|
+
// 能碰到内核的 permission / provider / io(审计 M-1 §2.1 第 4 条)。
|
|
1056
|
+
result = await dispatch(prep.name, prep.args, toolCtx);
|
|
1072
1057
|
}
|
|
1073
1058
|
invalidateReadCache(prep); // 有副作用 → 作废本回合只读缓存(写后再读必须看到新内容)
|
|
1074
1059
|
if (dedupKey) turnToolCache.set(dedupKey, result);
|
|
@@ -1139,16 +1124,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1139
1124
|
error: rObj2?.error ?? null,
|
|
1140
1125
|
});
|
|
1141
1126
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1127
|
+
// 序列化(字符串原样 / 对象紧凑 JSON)单源在 tools-flow.js:**先序列化、再问约束**,
|
|
1128
|
+
// 与抽取前的求值顺序一字不差(原来这两步就是这个先后)。
|
|
1129
|
+
const text = serializeToolResult(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
1144
1130
|
// v0.5.0 A3 ②:领域约束(PostToolUse)——completeness 缺项时**拒绝该工具结果**,
|
|
1145
1131
|
// 让模型必须继续采集而不是把「未提及」当作已完成(「缺项绝不编造」从提示词升级为内核强制)。
|
|
1146
1132
|
const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
|
|
1147
|
-
if (pv?.rejected)
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1133
|
+
if (pv?.rejected) auditConstraint(pv.event);
|
|
1134
|
+
// 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
|
|
1135
|
+
messages.push({
|
|
1136
|
+
role: 'tool',
|
|
1137
|
+
tool_call_id: prep.tc.id,
|
|
1138
|
+
content: toolResultMessage(text, { cached: prep.cached, cap: toolResultCap, post: pv }),
|
|
1139
|
+
});
|
|
1152
1140
|
}
|
|
1153
1141
|
|
|
1154
1142
|
let i = 0;
|