@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/cordis.patch.yml +399 -0
  4. package/package.json +32 -4
  5. package/tools/dsh-plugin-fly-binary/index.js +520 -0
  6. package/tools/dsh-plugin-fly-binary/package.json +23 -0
  7. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
  8. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
  9. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
  10. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
  11. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
  12. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
  13. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
  14. package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
  15. package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
  16. package/tools/dsh-plugin-fly-diag/index.js +772 -0
  17. package/tools/dsh-plugin-fly-diag/package.json +24 -0
  18. package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
  19. package/tools/dsh-plugin-fly-guard/index.js +101 -0
  20. package/tools/dsh-plugin-fly-guard/package.json +26 -0
  21. package/tools/dsh-plugin-fly-pwn/index.js +583 -0
  22. package/tools/dsh-plugin-fly-pwn/package.json +23 -0
  23. package/tools/dsh-plugin-fly-recon/index.js +184 -0
  24. package/tools/dsh-plugin-fly-recon/package.json +23 -0
  25. package/tools/dsh-plugin-fly-reverse/index.js +178 -0
  26. package/tools/dsh-plugin-fly-reverse/package.json +23 -0
@@ -0,0 +1,1453 @@
1
+ /**
2
+ * fly-blackbox — 客户端黑盒排查能力(skill + 工具)。
3
+ *
4
+ * 这个包把一次真实事件(某小程序"性别字段死锁"导致账号无法报名)里沉淀出的客户端
5
+ * 黑盒排查能力收进 Fly 安全模式,分两层交付:
6
+ *
7
+ * · **知识层**:一个 bundled skill(`assets/client-blackbox/`),按需加载。agent 读到的是
8
+ * 心智模型(三源一闸 S1/S2/S3/G)、六条判据 T1–T6、五阶段带 Gate 流程与可复用脚本模板。
9
+ * 知识本身不产生副作用,所以不受授权闸门管。
10
+ * · **执行层**:六个工具,走宿主 `shell` 接缝。它只管**可观测性与判定**(起代理、判管道、
11
+ * 读抓包、比对前后),改写路径(请求/响应注入)必须显式授权。
12
+ *
13
+ * 三条纪律写死在实现里,因为每一条都对应一次实测事故:
14
+ *
15
+ * 1. **一律经 `ctx.shell`**:`ShellExecutor` 只有 `resolve()` + `execute()`(0.1.7-rc.2 与
16
+ * 0.2.0-rc.2 都是),绝不 `child_process`;`node:fs` 只用于两处:读自带 skill 资产、
17
+ * 读写工作区内的自身状态文件。**用户数据(dump / 规则文件)必须走接缝**,这样沙箱拒绝
18
+ * 会以 `SandboxError` 报出来,而不是一个裸 EACCES。
19
+ * 2. **`truncated` 即失败**:`result.stdout.truncated` / `spillPath` 出现就报错。对 base64
20
+ * 读 dump 尤其致命 —— 截断的 base64 会解出半个 dump,然后给出"抓到的就这些"的假结论。
21
+ * 3. **永不提权**:`runCmd` 在执行前拒绝 `sudo` 与 `osascript … administrator privileges`。
22
+ * 理由不是审美:提权调用在沙箱里弹不出框也没有超时,而 `execute` 要 `await result()`,
23
+ * 真会话里那会**无超时地挂住**。需要人跑的命令只作为**文本**输出,不经过接缝。
24
+ *
25
+ * 缺依赖的口径是**大声**:macOS 没有 readelf/nmap 时仓库内补(见 fly-binary/recon),
26
+ * 但 TLS 拦截**没有纯仓内替代**(只转发 CONNECT 的代理看得到域名、看不到 body,而这类
27
+ * 缺陷恰恰在 body 里),所以这里既不假装能抓、也不静默降级:`bx_proxy status` 会报出
28
+ * 点名解释器的两行安装命令,`bx_doctor` 把"无抓包能力"当一等状态。
29
+ *
30
+ * 静态禁项(`scripts/verify-blackbox.mjs` 剥离注释后扫描):无 `shell.run(` / `shell.start(` /
31
+ * `child_process`;`inject` 恰好是 `['tools','shell','skills']`。
32
+ */
33
+ import { createHash } from 'node:crypto'
34
+ // `node:fs` 的**两个允许用途**(design §3.1):① 下面三行读自带 skill 资产;
35
+ // ② `writeState`/`clearState` 读写工作区内的自身状态文件。用户数据(dump、规则文件)
36
+ // 一律走 `shell` 接缝,这样沙箱拒绝会以 SandboxError 报出来。
37
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
38
+ import { join } from 'node:path'
39
+ import { fileURLToPath } from 'node:url'
40
+ import { defineTool } from '@deepseek-ai/dsh-tools'
41
+ import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
42
+
43
+ export const name = 'fly-blackbox'
44
+ /** 少一个 = 整棵 preset 被拒:provider 在 `apply()` 里注册,注不到 `skills` 就抛。 */
45
+ export const inject = ['tools', 'shell', 'skills']
46
+
47
+ /** 本包实现的 flow 文件格式版本(见 §B1 的版本闸)。 */
48
+ const FLOW_FORMAT_VERSION = 21
49
+
50
+ /** `assets/client-blackbox` 的绝对路径(打包版走真实路径,不是 SEA)。 */
51
+ const ASSET_ROOT = fileURLToPath(new URL('./assets/client-blackbox/', import.meta.url))
52
+ /** 自带资产:一个都不能少,缺任一 `apply()` 就抛(宁可整棵 preset 被拒,也不要静默少一个 skill)。 */
53
+ const ASSETS = [
54
+ 'SKILL.md',
55
+ 'reference/playbook.md',
56
+ 'reference/checklist.md',
57
+ 'reference/case-ttw.md',
58
+ 'reference/rules.schema.md',
59
+ 'code/mitm_addon_template.py',
60
+ 'code/rules.example.json',
61
+ ]
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // §B1 tnetstring 读取器(纯函数,门禁直接 import)
65
+ //
66
+ // mitmproxy 的 dump 是"长度前缀 + 载荷 + 类型标记"的 tnetsing 串接:
67
+ // `<len>:<payload><marker>`;marker:`;`=str、`,`=bytes、`#`=int、`^`=float、
68
+ // `!`=bool、`~`=null、`]`=list、`}`=dict。
69
+ //
70
+ // 与上游 `mitmproxy/io/tnetstring.py`(12.2.3)逐条对齐的三点,每一点都踩过或差点踩:
71
+ // · dict 的**键**也走 `;`(str),不是 bytes —— 所以 `version` 在线上是 `7:version;`,
72
+ // 值 `21` 是 int(`2:21#`,长度前缀 2)。**长度前缀随位数变**,改版本号不能只替换数字。
73
+ // · `~` 空值(`0:~`)是**常态**(`websocket: null`),不是"首条占位" —— 必须当普通值。
74
+ // · str 严格 UTF-8 解码:坏字节**抛错**,绝不产生 U+FFFD(否则"非 UTF-8 判据"被打穿)。
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ /** Python `int(...)` 接受的十进制字面量(拒绝 `+5` / ` 5` / `1_0` 之外的宽松形态)。 */
78
+ const INT_LITERAL = /^[+-]?\d+$/u
79
+ /** Python `float(...)` 接受的形态;`1.` / `.5` / `1e3` 都算,空的 `#` 不算。 */
80
+ const FLOAT_LITERAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/u
81
+
82
+ /** 大写十六进制,用于坏字节回显(`hex` 字段与 `non-utf8 header` 的载荷)。 */
83
+ export function hexOf(buffer, limit = 64) {
84
+ return buffer.subarray(0, limit).toString('hex').toUpperCase()
85
+ }
86
+
87
+ /**
88
+ * 把一个字节串按**严格** UTF-8 解码。
89
+ * @returns 解码文本,或 `null`(有坏字节时)。
90
+ */
91
+ export function strictUtf8(buffer) {
92
+ try {
93
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 从 `cursor` 处读一个 tnetstring 值的长度前缀,返回 `{ length, payloadAt }`。
101
+ *
102
+ * 长度前缀超过 12 位数字视为"荒谬的长度前缀"(上游同款保护)。**抛出的错误带
103
+ * `code:'TRUNCATED'`**:只有"读着读着到头了"才算截断,调用方据此区分尾部半条记录
104
+ * 与中间损坏 —— 靠匹配错误文本区分是脆的。
105
+ */
106
+ function readLength(bytes, from, what) {
107
+ let at = from
108
+ while (at < bytes.length && bytes[at] >= 0x30 && bytes[at] <= 0x39) {
109
+ at += 1
110
+ if (at - from > 12) throw new Error(`tnetstring: absurdly large length prefix at byte ${from} (${what})`)
111
+ }
112
+ if (at >= bytes.length || bytes[at] !== 0x3a) {
113
+ const error = new Error(`tnetstring: missing or invalid length prefix at byte ${from} (${what})`)
114
+ error.code = 'TRUNCATED'
115
+ throw error
116
+ }
117
+ return { length: Number(bytes.toString('latin1', from, at)), payloadAt: at + 1 }
118
+ }
119
+
120
+ /**
121
+ * 解析 `bytes` 中 `at` 处的一个值。
122
+ * @returns `{ value, next }`,`next` 是下一个值的起点。
123
+ */
124
+ function parseValue(bytes, at, path) {
125
+ const { length, payloadAt } = readLength(bytes, at, path)
126
+ if (payloadAt + length > bytes.length) {
127
+ const error = new Error(`tnetstring: value at byte ${at} claims ${length} payload bytes but only ${bytes.length - payloadAt} remain (${path})`)
128
+ error.code = 'TRUNCATED'
129
+ throw error
130
+ }
131
+ const payload = bytes.subarray(payloadAt, payloadAt + length)
132
+ const markerAt = payloadAt + length
133
+ if (markerAt >= bytes.length) {
134
+ const error = new Error(`tnetstring: value at byte ${at} has no type tag — the dump ends inside a record (${path})`)
135
+ error.code = 'TRUNCATED'
136
+ throw error
137
+ }
138
+ const marker = String.fromCharCode(bytes[markerAt])
139
+ const next = markerAt + 1
140
+ const where = `${path} @byte ${at}`
141
+
142
+ if (marker === ';') {
143
+ const text = strictUtf8(payload)
144
+ if (text === null) throw new Error(`tnetstring: ${where} is not valid UTF-8: hex ${hexOf(payload)}`)
145
+ return { value: text, next }
146
+ }
147
+ if (marker === ',') return { value: Buffer.from(payload), next }
148
+ if (marker === '#') {
149
+ const literal = payload.toString('latin1')
150
+ if (!INT_LITERAL.test(literal)) throw new Error(`tnetstring: ${where} has an invalid integer literal ${JSON.stringify(literal)}`)
151
+ return { value: Number.parseInt(literal, 10), next }
152
+ }
153
+ if (marker === '^') {
154
+ const literal = payload.toString('latin1')
155
+ if (!FLOAT_LITERAL.test(literal)) throw new Error(`tnetstring: ${where} has an invalid float literal ${JSON.stringify(literal)}`)
156
+ return { value: Number.parseFloat(literal), next }
157
+ }
158
+ if (marker === '!') {
159
+ if (length === 4 && payload.toString('latin1') === 'true') return { value: true, next }
160
+ if (length === 5 && payload.toString('latin1') === 'false') return { value: false, next }
161
+ throw new Error(`tnetstring: ${where} has an invalid boolean literal ${JSON.stringify(payload.toString('latin1'))}`)
162
+ }
163
+ if (marker === '~') {
164
+ if (length !== 0) throw new Error(`tnetstring: ${where} has an invalid null literal (${length} bytes of payload)`)
165
+ return { value: null, next }
166
+ }
167
+ if (marker === ']') {
168
+ const items = []
169
+ let inner = payloadAt
170
+ let index = 0
171
+ while (inner < markerAt) {
172
+ const item = parseValue(bytes, inner, `${path}[${index}]`)
173
+ items.push(item.value)
174
+ inner = item.next
175
+ index += 1
176
+ }
177
+ if (inner !== markerAt) throw new Error(`tnetstring: ${where} list payload does not end on its marker`)
178
+ return { value: items, next }
179
+ }
180
+ if (marker === '}') {
181
+ const object = {}
182
+ let inner = payloadAt
183
+ while (inner < markerAt) {
184
+ const key = parseValue(bytes, inner, `${path}.<key>`)
185
+ const item = parseValue(bytes, key.next, `${path}.${String(key.value)}`)
186
+ object[key.value] = item.value
187
+ inner = item.next
188
+ }
189
+ if (inner !== markerAt) throw new Error(`tnetstring: ${where} dict payload does not end on its marker`)
190
+ return { value: object, next }
191
+ }
192
+ throw new Error(`tnetstring: ${where} has an unknown type tag ${JSON.stringify(marker)}`)
193
+ }
194
+
195
+ /**
196
+ * 读一个 dump 里的**全部** flow(一条记录一个 tnetstring)。
197
+ *
198
+ * 结束条件是"读指针 == 文件长度";**尾部**半条记录 → 明说读到第 N 字节截断、可能有 1 条
199
+ * 未完成;**中间**损坏(长度前缀/类型标记不对,且后面还剩下完整字节)→ 抛错。
200
+ *
201
+ * 两类失败的判据不是错误文本而是**剩余字节**:记录从 `cursor` 起解不动、且失败点已经吃到
202
+ * 文件末尾 → 截断;失败点后面还有整条记录的字节 → 中间损坏。
203
+ *
204
+ * @param buffer - 整个 dump 的字节。
205
+ * @returns `{ flows, cursor, fileLength, truncated, note }`。
206
+ */
207
+ export function readFlows(buffer) {
208
+ const flows = []
209
+ let cursor = 0
210
+ let truncated = false
211
+ let note = ''
212
+ while (cursor < buffer.length) {
213
+ const at = cursor
214
+ let parsed
215
+ try {
216
+ parsed = parseValue(buffer, cursor, 'flow')
217
+ } catch (error) {
218
+ const remainder = buffer.length - cursor
219
+ const truncatedShape = error.code === 'TRUNCATED'
220
+ if (truncatedShape) {
221
+ truncated = true
222
+ note = `读到第 ${cursor} 字节截断,可能有 1 条未完成(从该处起只剩 ${remainder} 字节)`
223
+ break
224
+ }
225
+ throw new Error(`${error.message}(在第 ${flows.length + 1} 条记录、字节 ${at} 处;后面还有 ${remainder} 字节,属中间损坏而不是尾部截断)`)
226
+ }
227
+ if (Array.isArray(parsed.value) || Buffer.isBuffer(parsed.value) || parsed.value === null || typeof parsed.value !== 'object') {
228
+ throw new Error(`tnetstring: byte ${at} holds a ${parsed.value === null ? 'null' : typeof parsed.value}, not a flow dict — wrong file?`)
229
+ }
230
+ flows.push(parsed.value)
231
+ cursor = parsed.next
232
+ }
233
+ // 末条记录必须**正好**吃到文件末尾:尾部截断可能落在"长度前缀声明得太大"上(parse 会抛
234
+ // TRUNCATED),也可能**恰好落在剩余字节里**(parse 成功但记录少了一截)。后一种会在光标处
235
+ // 留下半条尾巴,所以用它当判据 —— 不靠"解析器有没有抛错"。
236
+ if (cursor !== buffer.length) {
237
+ truncated = true
238
+ note = `读到第 ${cursor} 字节截断,可能有 1 条未完成(从该处起只剩 ${buffer.length - cursor} 字节);已读到的 ${flows.length} 条是完整的。`
239
+ }
240
+ return { flows, cursor, fileLength: buffer.length, truncated, note }
241
+ }
242
+
243
+ /**
244
+ * 版本闸:**接受 ≤ 本包实现的版本,拒绝更高**。
245
+ *
246
+ * 上游不是"必须等于":`mitmproxy/io/compat.py:512-525` 的 `migrate_flow` 只在
247
+ * `flow_version > version.FLOW_FORMAT_VERSION` 时抛 `cannot read files with flow format
248
+ * version {v}, please update mitmproxy`,低于它的一律走 `converters` 升级。纯 JS 读取器
249
+ * 没有那套 converter,但"旧结构"恰恰是**同一个结构**(本读取器只依赖字段名,不依赖
250
+ * 版本特性),所以口径照上游:高了拒绝,低了照读。
251
+ *
252
+ * **注意:它是导出给调用侧的,`readFlows()` 自己不调用它**(读取器只管字节 → 结构)。
253
+ * 所以"读取器有版本闸"这句话要读成"**读 dump 的那条工具路径有**":工具路径在 `readFlows()`
254
+ * 之后逐条过这里;单跑 `readFlows()` 喂一个 99 版的 dump 会照读不误(门禁第 13 条把两侧
255
+ * 都钉住:工具路径必须拒、手工配对也必须断)。
256
+ *
257
+ * @param flow - 一个已解析的 flow dict。
258
+ * @param context - 出错文案要回显的东西(文件路径、mitmdump 版本)。
259
+ */
260
+ export function assertReadableVersion(flow, context = {}) {
261
+ const raw = flow?.version
262
+ const dumpVersion = typeof raw === 'number' ? raw : undefined
263
+ if (dumpVersion !== undefined && dumpVersion <= FLOW_FORMAT_VERSION) return dumpVersion
264
+ const where = context.path === undefined ? '' : `${context.path}: `
265
+ const hint = context.mitmdumpVersion === undefined ? '' : `\n本机 mitmdump --version:${context.mitmdumpVersion}`
266
+ throw new Error(
267
+ `${where}这个 dump 的 flow 格式版本是 ${dumpVersion === undefined ? `不可解析的 ${JSON.stringify(raw)}` : dumpVersion},`
268
+ + `本包实现的版本是 ${FLOW_FORMAT_VERSION};版本更高的 dump 结构可能已经变了,拒绝按旧结构硬解。`
269
+ + `\n修法:用与 dump 同版本的 mitmproxy 重新导出,或升级 fly-blackbox 的读取器。${hint}`,
270
+ )
271
+ }
272
+
273
+ // ─────────────────────────────────────────────────────────────────────────────
274
+ // §B4 接缝纪律
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+
277
+ function shq(value) {
278
+ return `'${String(value).replace(/'/gu, "'\\''")}'`
279
+ }
280
+
281
+ /** 执行前拒绝提权:`sudo` 出现在命令里,或 `osascript` + `administrator privileges`。 */
282
+ function assertNotPrivileged(command) {
283
+ if (/(^|[\s;&|(])sudo\b/u.test(command) || (/osascript/u.test(command) && /administrator privileges/u.test(command))) {
284
+ throw new Error(
285
+ `拒绝执行提权命令(本包永不提权):${command}\n`
286
+ + '理由:提权调用弹不出授权框且没有超时,而 execute 要 await result(),真会话里会无超时挂住。'
287
+ + '需要人跑的提权命令由 bx_ca command 输出成文本,用户自己复制到终端执行。',
288
+ )
289
+ }
290
+ }
291
+
292
+ /**
293
+ * 跑一条命令 —— **每一条执行路径的唯一入口**。
294
+ *
295
+ * 与 `fly-recon` 的 `runCmd` 同形,但多两条:① 执行前拒提权(§B1 的纪律三);
296
+ * ② **读截断信息**:`stdout.truncated === true` 或出现 `spillPath` 一律当失败。
297
+ * 既有五个安全插件的 `runCmd` 不读 `truncated`(当前输出都远低于上限所以没触发,
298
+ * 见 docs/capability-iterations.md 的"已知缺口")—— 新包不许继承这条。
299
+ *
300
+ * **`result` 是方法,不是属性**:`ShellExecution.result()`(类型见 `dsh-shell` 的
301
+ * `types.d.ts`)。写成 `await handle.result` 会 await 到**函数本身**(不是 thenable ⇒
302
+ * 原样返回),随后 `result.stdout.text` 抛 `TypeError: Cannot read properties of
303
+ * undefined (reading 'text')` —— 每一个工具调用都炸,而"只提供 result 属性"的桩看不见
304
+ * 这一点(本仓既有五个插件的 `runCmd` 正是这个写法;门禁桩现在按真接缝的形状发布
305
+ * `result()`,所以这类"桩和真接缝不同形"的假绿会在这一层被抓住)。
306
+ */
307
+ async function runCmd(shell, argv, exec, opts = {}) {
308
+ const command = typeof argv === 'string' ? argv : argv.map(shq).join(' ')
309
+ assertNotPrivileged(command)
310
+ const handle = await shell.execute(shell.resolve({
311
+ command,
312
+ ...opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {},
313
+ ...opts.stdoutMaxBytes !== undefined ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
314
+ ...opts.onExpiry !== undefined ? { onExpiry: opts.onExpiry } : {},
315
+ signal: exec.signal,
316
+ }))
317
+ const result = await handle.result()
318
+ if (result.aborted) {
319
+ const error = new Error('tool call aborted')
320
+ error.name = 'AbortError'
321
+ throw error
322
+ }
323
+ if (result.sandbox?.denied) {
324
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
325
+ error.name = 'SandboxError'
326
+ throw error
327
+ }
328
+ if (result.stdout?.truncated === true || result.stdout?.spillPath !== undefined) {
329
+ const spill = result.stdout?.spillPath === undefined ? '' : `(溢写到 ${result.stdout.spillPath})`
330
+ throw new Error(
331
+ `命令输出被截断${spill},拒绝把不完整的输出当结果:${command}\n`
332
+ + `stdoutMaxBytes=${opts.stdoutMaxBytes ?? '(默认)'};请调大 stdoutMaxBytes 或先用过滤器把输入缩小。`,
333
+ )
334
+ }
335
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command }
336
+ }
337
+
338
+ /**
339
+ * 起一个**常驻**进程(代理)—— 与 `runCmd` 的唯一区别是**不 await `result`**。
340
+ *
341
+ * 依据是 `dsh-shell` 的 `ShellExecutor` 契约原文(0.1.7-rc.2 / 0.2.0-rc.2 一致):
342
+ * "execute resolves with the process handle after preparation. 'Foreground' is a
343
+ * property of what the caller awaits, not of the spawn — a caller that awaits
344
+ * `ShellExecution.result` ran the command in the foreground; one that keeps the
345
+ * handle ran it in the background." 而 `handle.result()` 直到**进程退出**才 settle。
346
+ *
347
+ * 代理是常驻进程 ⇒ 只要 await 了 `result`,`bx_proxy start` 就会一直挂到 mitmdump
348
+ * 退出为止 —— 真会话里的症状是"工具不返回"(不是报错、也没有超时:`onExpiry:'none'`
349
+ * 不挂 deadline)。所以这里刻意只 await 到"进程已起来",就绪判定交给调用方有界的
350
+ * `lsof` 轮询。
351
+ *
352
+ * 实测证据(真 `dsh-bash-local` 0.2.0-rc.2 + `dsh-subprocess-local`,`sleep 30` +
353
+ * `onExpiry:'none'`):`execute()` 2ms 返回句柄,`handle.result()` 2 秒内不 settle。
354
+ *
355
+ * 接缝桩**故意让长跑 spec 的 `result` 永不 settle**(门禁第 8 条),于是"有没有 await
356
+ * result"这件事变成可判的:一旦有人改回 `await runCmd(...)`,第 8 条就会红(挂住)。
357
+ */
358
+ async function startCmd(shell, argv, exec, opts = {}) {
359
+ const command = typeof argv === 'string' ? argv : argv.map(shq).join(' ')
360
+ assertNotPrivileged(command)
361
+ const handle = await shell.execute(shell.resolve({
362
+ command,
363
+ ...opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {},
364
+ ...opts.stdoutMaxBytes !== undefined ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
365
+ ...opts.onExpiry !== undefined ? { onExpiry: opts.onExpiry } : {},
366
+ // 环境变量是**唯一**能在 addon 的 `load()` 之前就生效的通道(见 `start` 里的注释)。
367
+ ...opts.env !== undefined ? { env: opts.env } : {},
368
+ signal: exec.signal,
369
+ }))
370
+ /** 现在为止能读到的输出(早退时的诊断用;`readOutput` 是接缝上的增量读)。 */
371
+ const peek = () => {
372
+ const read = handle.readOutput?.()
373
+ return typeof read?.delta === 'string' ? read.delta : ''
374
+ }
375
+ return { handle, command, peek }
376
+ }
377
+
378
+ /** 失败的命令绝不返回空串(空输出会被读成"工具说没有")。 */
379
+ function requireOk(run, what) {
380
+ if (run.exitCode === 0) return run.stdout
381
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
382
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
383
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
384
+ error.name = 'CommandError'
385
+ throw error
386
+ }
387
+
388
+ /** 退出码 127 / "command not found":这个可选工具本机没有。 */
389
+ function missingTool(run) {
390
+ return run.exitCode === 127 || /command not found/iu.test(run.stderr)
391
+ }
392
+
393
+ // ─────────────────────────────────────────────────────────────────────────────
394
+ // 工作区、状态文件与规则文件
395
+ // ─────────────────────────────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * 这个会话的工作区根。
399
+ *
400
+ * 取不到就**大声报错**:`process.cwd()` 是宿主的 cwd,拿它顶替会把状态文件与 dump 路径
401
+ * 写到别处(AGENTS.md 坑表"禁 `process.cwd()` 兜底")。`FLY_BLACKBOX_WORKSPACE` 只给
402
+ * 无头/测试用。
403
+ */
404
+ function workspaceRoot(exec) {
405
+ const explicit = process.env.FLY_BLACKBOX_WORKSPACE
406
+ if (typeof explicit === 'string' && explicit.trim() !== '') return explicit
407
+ for (const candidate of [exec?.workspace, exec?.cwd, exec?.session?.workspace, exec?.agent?.session?.workspace]) {
408
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
409
+ }
410
+ throw new Error(
411
+ '取不到当前会话的工作区(exec.workspace / exec.cwd / exec.session.workspace 都是空),'
412
+ + '拒绝用宿主 cwd 顶替:状态文件与 dump 路径会跟着错。'
413
+ + '无头或验证场景请显式设 FLY_BLACKBOX_WORKSPACE=<绝对路径>。',
414
+ )
415
+ }
416
+
417
+ /**
418
+ * 读一行文本,走接缝。用于**用户数据**(dump、规则文件)—— 这样沙箱拒绝会以
419
+ * `SandboxError` 报出。允许 fs 的两处例外(自带资产 / 自身状态文件)见 §3.1。
420
+ */
421
+ async function readTextFileVia(shell, exec, path, what, opts = {}) {
422
+ const run = await runCmd(shell, ['base64', '-i', path], exec, { stdoutMaxBytes: 64 * 1024 * 1024 })
423
+ if (missingTool(run)) throw new Error(`${what} 读不到:${path} —— 本机没有 base64(不应发生)`)
424
+ if (run.exitCode !== 0) throw new Error(`${what} 读不到:${path} —— ${run.stderr.trim().split('\n').at(-1) ?? `exit ${run.exitCode}`}`)
425
+ // `allowEmpty`:规则文件走它 —— 空文件要由 `validateRules` 给出**说得通**的理由
426
+ // ("空文件曾经静默回退成默认注入"),而不是在这里丢一句干巴巴的"读出来是空的"。
427
+ return decodeBase64(run.stdout, what, path, opts)
428
+ }
429
+
430
+ /** macOS/Linux 的 `base64` 默认会在 stdout 里插换行(`-b` 更狠),解码前一律剔除空白。 */
431
+ export function decodeBase64(text, what, path, opts = {}) {
432
+ const compact = text.replace(/\s+/gu, '')
433
+ if (compact === '' && opts.allowEmpty !== true) throw new Error(`${what} 读出来是空的:${path}`)
434
+ return Buffer.from(compact, 'base64')
435
+ }
436
+
437
+ /** `stat -f%z <path>`(BSD;根目录会多打 `File: …` 一行,所以取最后一行)。 */
438
+ async function statSizeVia(shell, exec, path, what) {
439
+ const run = await runCmd(shell, ['stat', '-f%z', path], exec, { timeoutMs: 15_000 })
440
+ if (run.exitCode !== 0) throw new Error(`${what} 不存在或读不到:${path} —— ${run.stderr.trim().split('\n').at(-1) ?? `exit ${run.exitCode}`}`)
441
+ const last = run.stdout.trim().split('\n').at(-1)?.trim() ?? ''
442
+ if (!/^\d+$/u.test(last)) throw new Error(`${what} 的大小读不出来:stat 返回 ${JSON.stringify(last)}`)
443
+ return Number(last)
444
+ }
445
+
446
+ /** 状态文件在**工作区内**(唯一允许的自身状态写入面)。 */
447
+ function statePath(root) {
448
+ return join(root, '.fly-blackbox', 'proxy.json')
449
+ }
450
+
451
+ function readState(root) {
452
+ try {
453
+ return JSON.parse(readFileSync(statePath(root), 'utf8'))
454
+ } catch {
455
+ return undefined
456
+ }
457
+ }
458
+
459
+ function writeState(root, state) {
460
+ const path = statePath(root)
461
+ // `node:fs` 的第二个允许用途:自身状态文件(工作区内)。同步写,因为 `start` 必须在
462
+ // 返回前把 PID 落盘,否则紧随其后的 status 会读到上一个实例。
463
+ mkdirSync(join(root, '.fly-blackbox'), { recursive: true })
464
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
465
+ return path
466
+ }
467
+
468
+ function clearState(root) {
469
+ rmSync(statePath(root), { force: true })
470
+ }
471
+
472
+ /** 规则文件:固定 `<workspace>/.fly-blackbox/rules.json`,`bx_proxy start` 只读并校验。 */
473
+ function rulesPath(root, override) {
474
+ return typeof override === 'string' && override.trim() !== '' ? override : join(root, '.fly-blackbox', 'rules.json')
475
+ }
476
+
477
+ /**
478
+ * 校验规则文件。
479
+ *
480
+ * **空文件 / 缺键 = 非法**:原会话的 `set_inject.sh off` 写了一个空文件,addon 静默回退到
481
+ * 默认注入继续改包 —— 这是整个复盘里最贵的一个坑。所以"关掉注入"必须是**显式**字段
482
+ * (`{"enabled": false}`),缺键不算关。
483
+ */
484
+ function validateRules(text, path) {
485
+ if (text.trim() === '') return { ok: false, reason: `规则文件是空的:${path} —— 空文件曾经静默回退成"默认注入",所以这里一律当非法。要关掉注入请显式写 {"enabled": false}` }
486
+ let rules
487
+ try {
488
+ rules = JSON.parse(text)
489
+ } catch (error) {
490
+ return { ok: false, reason: `规则文件不是合法 JSON:${path} —— ${error.message}` }
491
+ }
492
+ if (typeof rules !== 'object' || rules === null || Array.isArray(rules)) {
493
+ return { ok: false, reason: `规则文件的顶层必须是对象:${path}` }
494
+ }
495
+ if (rules.enabled === false) return { ok: true, rules, summary: 'enabled=false(只记录,不注入)' }
496
+ if (rules.enabled !== true && rules.enabled !== undefined) {
497
+ return { ok: false, reason: `规则文件的 enabled 必须是布尔:${path}` }
498
+ }
499
+ for (const key of ['watch', 'patch_request', 'patch_response']) {
500
+ if (rules[key] !== undefined && typeof rules[key] !== 'object') {
501
+ return { ok: false, reason: `规则文件的 ${key} 必须是对象/数组:${path}` }
502
+ }
503
+ }
504
+ for (const key of ['patch_request', 'patch_response']) {
505
+ if (rules[key] === undefined) continue
506
+ if (!Array.isArray(rules[key])) return { ok: false, reason: `规则文件的 ${key} 必须是数组:${path}` }
507
+ for (const [index, rule] of rules[key].entries()) {
508
+ if (typeof rule !== 'object' || rule === null) return { ok: false, reason: `${key}[${index}] 必须是对象:${path}` }
509
+ if (typeof rule.path !== 'string' || rule.path === '') return { ok: false, reason: `${key}[${index}] 缺少 path(正则字符串):${path}` }
510
+ if (rule.mode !== undefined && !['set_if_empty', 'force', 'only_missing'].includes(rule.mode)) {
511
+ return { ok: false, reason: `${key}[${index}].mode 只能是 set_if_empty / force / only_missing:${path}` }
512
+ }
513
+ if (rule.set === undefined || typeof rule.set !== 'object' || rule.set === null) {
514
+ return { ok: false, reason: `${key}[${index}] 缺少 set 对象:${path}` }
515
+ }
516
+ }
517
+ }
518
+ return { ok: true, rules, summary: `enabled=${rules.enabled === true ? 'true' : '(未声明)'}, patch_request ${rules.patch_request?.length ?? 0} 条, patch_response ${rules.patch_response?.length ?? 0} 条` }
519
+ }
520
+
521
+ // ─────────────────────────────────────────────────────────────────────────────
522
+ // mitmproxy 定位与安装指引
523
+ // ─────────────────────────────────────────────────────────────────────────────
524
+
525
+ /** `FLY_MITM_BIN` 指向 mitmdump;缺省走 PATH。 */
526
+ function mitmBin() {
527
+ const configured = process.env.FLY_MITM_BIN
528
+ return typeof configured === 'string' && configured.trim() !== '' ? configured : 'mitmdump'
529
+ }
530
+
531
+ /** 本机 pip3 是 3.9.6,直接装只会得到 mitmproxy 9.0.1(dump 格式与 12.x 不同)。 */
532
+ const INTERPRETER = '/opt/homebrew/bin/python3.14'
533
+ const INSTALL_LINES = [
534
+ `${INTERPRETER} -m venv .venv-mitm`,
535
+ `.venv-mitm/bin/pip install -i https://pypi.org/simple mitmproxy`,
536
+ ]
537
+
538
+ /** 缺 mitmdump 时的**大声**报错文案:两行命令 + 为什么必须点名解释器。 */
539
+ function missingMitmdumpMessage(detail) {
540
+ return [
541
+ `本机没有可用的 mitmdump${detail === '' ? '' : `(${detail})`} —— 这台机器上**没有抓包能力**,不是"追查失败"。`,
542
+ '',
543
+ '装上它(两行,点名解释器很重要):',
544
+ ...INSTALL_LINES.map(line => ` ${line}`),
545
+ '',
546
+ '为什么必须点名 /opt/homebrew/bin/python3.14:本机 pip3 是 3.9.6,直接 `pip3 install mitmproxy`',
547
+ '只会得到 9.0.1,而它写的 dump 格式与 12.x 不同(读取器会按版本闸拒绝)。',
548
+ '本机 pip 默认镜像曾解析失败(清华镜像连 six 都拉不到,报 `from versions: none`),',
549
+ '所以上面显式用 `-i https://pypi.org/simple` 走官方源。',
550
+ '',
551
+ '装完用 `bx_proxy start` 会回显 `mitmdump --version`。',
552
+ ].join('\n')
553
+ }
554
+
555
+ /** 查 mitmdump 版本;缺了就抛装配指引。 */
556
+ async function mitmdumpVersion(shell, exec) {
557
+ const bin = mitmBin()
558
+ const run = await runCmd(shell, [bin, '--version'], exec, { timeoutMs: 20_000 })
559
+ if (run.exitCode !== 0) throw new Error(missingMitmdumpMessage(missingTool(run) ? `${bin}: command not found` : run.stderr.trim().split('\n')[0] ?? `exit ${run.exitCode}`))
560
+ return run.stdout.trim().split('\n').map(line => line.trim()).filter(Boolean).join('\n')
561
+ }
562
+
563
+ /** 复用的端口查询:谁在 LISTEN 这个端口。 */
564
+ async function listenersOn(shell, exec, port) {
565
+ const run = await runCmd(shell, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], exec, { timeoutMs: 20_000 })
566
+ if (run.exitCode !== 0) return []
567
+ return run.stdout.split('\n').slice(1).map(line => line.trim()).filter(Boolean)
568
+ }
569
+
570
+ /** 状态 + 现实的一致性:`kill -0`(进程)与 `lsof`(端口)必须一致。 */
571
+ async function proxyStatusLines(shell, exec, root) {
572
+ const state = readState(root)
573
+ const port = state?.port ?? Number(process.env.FLY_PROXY_PORT ?? 8080)
574
+ if (state === undefined) return { state, port, alive: false, listeners: [], stale: false }
575
+ const alive = (await runCmd(shell, ['kill', '-0', String(state.pid)], exec, { timeoutMs: 15_000 })).exitCode === 0
576
+ const listeners = await listenersOn(shell, exec, state.port)
577
+ const holding = listeners.some(line => new RegExp(`\\b${state.pid}\\b`, 'u').test(line))
578
+ return { state, port: state.port ?? port, alive, listeners, stale: alive !== holding || (!alive && listeners.length > 0) }
579
+ }
580
+
581
+ function describeState(state) {
582
+ if (state === undefined) return '没有状态文件(`bx_proxy start` 未在这个工作区起过实例)'
583
+ return `PID ${state.pid} · port ${state.port} · confdir ${state.confdir} · dump ${state.dump} · 规则 ${state.rules} · 起于 ${state.startedAt}`
584
+ }
585
+
586
+ // ─────────────────────────────────────────────────────────────────────────────
587
+ // §B2 `bx_flows` / `bx_diff` 的数据整形
588
+ // ─────────────────────────────────────────────────────────────────────────────
589
+
590
+ /** flow 里的"头部字节字段"(bytes):打印前显式解码,坏字节**报出来**,不当正文。 */
591
+ const HEADER_BYTES = {
592
+ request: ['method', 'path', 'scheme', 'authority', 'http_version'],
593
+ response: ['reason', 'http_version'],
594
+ }
595
+
596
+ function decodeHeader(buffer, where) {
597
+ const text = strictUtf8(buffer)
598
+ if (text === null) return { text: `non-utf8 header, hex ${hexOf(buffer)}`, bad: where }
599
+ return { text, bad: undefined }
600
+ }
601
+
602
+ /**
603
+ * body 的三种形态:`text` / `binary` / `none`。
604
+ *
605
+ * `binary` **不带 `text`**(一条非 UTF-8 的图片/字体会被 JSON 化成
606
+ * `{"type":"Buffer",…}`,那是给人看的垃圾而非数据),带 `bytes` + `sha256` + 前 64 字节 `hex`。
607
+ */
608
+ export function shapeBody(buffer) {
609
+ if (buffer === undefined || buffer === null) return { kind: 'none', bytes: 0 }
610
+ if (buffer.length === 0) return { kind: 'none', bytes: 0 }
611
+ const text = strictUtf8(buffer)
612
+ if (text !== null) return { kind: 'text', text, bytes: buffer.length }
613
+ return {
614
+ kind: 'binary',
615
+ bytes: buffer.length,
616
+ sha256: createHash('sha256').update(buffer).digest('hex'),
617
+ hex: hexOf(buffer),
618
+ }
619
+ }
620
+
621
+ function headerPairs(list, bad) {
622
+ if (!Array.isArray(list)) return []
623
+ return list.map((pair, index) => {
624
+ if (!Array.isArray(pair) || pair.length < 2) return ['<malformed>', `<entry ${index} is not a [name, value] pair>`]
625
+ const name = Buffer.isBuffer(pair[0]) ? decodeHeader(pair[0], `header name ${index}`) : { text: String(pair[0]) }
626
+ const value = Buffer.isBuffer(pair[1]) ? decodeHeader(pair[1], `header value ${index}`) : { text: String(pair[1]) }
627
+ if (name.bad !== undefined) bad.push(`${name.bad}: ${name.text}`)
628
+ if (value.bad !== undefined) bad.push(`${value.bad}: ${value.text}`)
629
+ return [name.text, value.text]
630
+ })
631
+ }
632
+
633
+ /** 把一条 flow 整形成 `bx_flows show --json` 的形状(也是 `bx_diff` 的输入契约)。 */
634
+ export function summarizeFlow(flow, index) {
635
+ const bad = []
636
+ const request = flow.request ?? {}
637
+ const response = flow.response ?? {}
638
+ const requestHeaders = headerPairs(request.headers, bad)
639
+ const responseHeaders = headerPairs(response.headers, bad)
640
+ const fields = {}
641
+ for (const key of HEADER_BYTES.request) {
642
+ const raw = request[key]
643
+ if (Buffer.isBuffer(raw)) {
644
+ const decoded = decodeHeader(raw, `request.${key}`)
645
+ fields[`request.${key}`] = decoded.text
646
+ if (decoded.bad !== undefined) bad.push(`${decoded.bad}: ${decoded.text}`)
647
+ } else if (raw !== undefined) {
648
+ fields[`request.${key}`] = String(raw)
649
+ }
650
+ }
651
+ for (const key of HEADER_BYTES.response) {
652
+ const raw = response[key]
653
+ if (Buffer.isBuffer(raw)) {
654
+ const decoded = decodeHeader(raw, `response.${key}`)
655
+ fields[`response.${key}`] = decoded.text
656
+ if (decoded.bad !== undefined) bad.push(`${decoded.bad}: ${decoded.text}`)
657
+ } else if (raw !== undefined) {
658
+ fields[`response.${key}`] = String(raw)
659
+ }
660
+ }
661
+ const status = response.status_code
662
+ const text = key => {
663
+ const value = request[key] ?? response[key]
664
+ return Buffer.isBuffer(value) ? (strictUtf8(value) ?? `<non-utf8 ${key}>`) : value === undefined ? undefined : String(value)
665
+ }
666
+ return {
667
+ index,
668
+ version: typeof flow.version === 'number' ? flow.version : flow.version,
669
+ type: flow.type ?? '(no type)',
670
+ // `host` 在 dump 里也是 `,`(bytes):**必须走 `text()` 解码**,不能把 Buffer 直接漏进
671
+ // 摘要 —— 漏了 `JSON.stringify` 就会吐出一坨 `{"type":"Buffer","data":[...]}`。
672
+ // 门禁第 12 条盯的就是这个(它真的红在这一行)。
673
+ host: text('host') ?? text('authority') ?? '(no host)',
674
+ method: text('method') ?? '(no method)',
675
+ path: text('path') ?? '(no path)',
676
+ scheme: text('scheme') ?? '',
677
+ status: typeof status === 'number' ? status : String(response.reason ?? '(no response)'),
678
+ websocket: flow.websocket === null || flow.websocket === undefined ? null : '(present)',
679
+ error: flow.error === null || flow.error === undefined ? null : String(flow.error),
680
+ requestHeaders,
681
+ responseHeaders,
682
+ requestBody: shapeBody(Buffer.isBuffer(request.content) ? request.content : undefined),
683
+ responseBody: shapeBody(Buffer.isBuffer(response.content) ? response.content : undefined),
684
+ badHeaders: bad,
685
+ }
686
+ }
687
+
688
+ /** `bx_flows list` 的一行。 */
689
+ function listLine(flow) {
690
+ const method = flow.method.padEnd(6)
691
+ const status = String(flow.status).padEnd(6)
692
+ const size = flow.requestBody.bytes + flow.responseBody.bytes
693
+ return `${String(flow.index).padStart(4)} ${method} ${status} ${flow.host}${flow.path} (${size} bytes)`
694
+ }
695
+
696
+ /** `show` 的人读形态(也把坏 header 明说)。 */
697
+ function showText(flow) {
698
+ const lines = [
699
+ `# flow ${flow.index} · type=${flow.type} · version=${flow.version}`,
700
+ `${flow.method} ${flow.scheme}://${flow.host}${flow.path} → ${flow.status}`,
701
+ '',
702
+ '── request headers ──',
703
+ ...flow.requestHeaders.map(([key, value]) => `${key}: ${value}`),
704
+ '',
705
+ `── request body (${flow.requestBody.kind}, ${flow.requestBody.bytes} bytes) ──`,
706
+ flow.requestBody.text ?? `(binary, sha256 ${flow.requestBody.sha256}, hex ${flow.requestBody.hex})`,
707
+ '',
708
+ '── response headers ──',
709
+ ...flow.responseHeaders.map(([key, value]) => `${key}: ${value}`),
710
+ '',
711
+ `── response body (${flow.responseBody.kind}, ${flow.responseBody.bytes} bytes) ──`,
712
+ flow.responseBody.text ?? `(binary, sha256 ${flow.responseBody.sha256}, hex ${flow.responseBody.hex})`,
713
+ ]
714
+ if (flow.badHeaders.length > 0) lines.push('', `⚠ ${flow.badHeaders.join('\n⚠ ')}`)
715
+ return lines.join('\n')
716
+ }
717
+
718
+ /** 所有 body 文本里搜关键词(T2"文案搜响应体"那一招)。 */
719
+ function grepText(flow, pattern) {
720
+ const haystack = [
721
+ flow.requestBody.text ?? '',
722
+ flow.responseBody.text ?? '',
723
+ ...flow.requestHeaders.map(([key, value]) => `${key}: ${value}`),
724
+ ...flow.responseHeaders.map(([key, value]) => `${key}: ${value}`),
725
+ ].join('\n')
726
+ return new RegExp(pattern, 'iu').test(haystack)
727
+ }
728
+
729
+ // ─────────────────────────────────────────────────────────────────────────────
730
+ // `bx_diff`:把"缺失 / 假值 / 类型变化"变成机器不变量
731
+ // ─────────────────────────────────────────────────────────────────────────────
732
+
733
+ const EMPTY_VALUES = new Set([null, '', 0, false])
734
+
735
+ function isEmptyValue(value) {
736
+ if (value === undefined) return true
737
+ if (value === null) return true
738
+ if (typeof value === 'string') return value === ''
739
+ if (typeof value === 'number') return value === 0
740
+ if (typeof value === 'boolean') return value === false
741
+ if (Array.isArray(value)) return value.length === 0
742
+ if (typeof value === 'object') return Object.keys(value).length === 0
743
+ return false
744
+ }
745
+
746
+ /**
747
+ * 字段级 diff。三种标注**永不混淆**:
748
+ * · `缺失` —— 键在新侧不存在(这是"字段被白名单丢掉"的形状)
749
+ * · `空值` —— 键存在但当下的值是 `null`/`""`/`0`/`false`(这是"假值陷阱"的形状)
750
+ * · `类型变化` —— `0` vs `"0"` 这种(这是"编码不一致"的形状)
751
+ *
752
+ * @param a - 旧 JSON 值。
753
+ * @param b - 新 JSON 值。
754
+ * @param path - 当前字段路径(顶层调用传 `''`)。
755
+ * @returns 每一行一个字段的差异描述。
756
+ */
757
+ export function diffJson(a, b, path = '') {
758
+ // 三种"没值"必须分清,混在一起就会把 T5 的假值陷阱报错:
759
+ // `undefined` → 键**不存在**(缺失)· `null` → 键在但值是**空值**
760
+ // `null` 也不要走下面的 `typeof` 分支:`typeof null === 'object'` 会把它误报成类型变化。
761
+ const missing = value => value === undefined
762
+ if (missing(a) && missing(b)) return []
763
+ if (missing(a)) return [`${path}: 缺失 → ${JSON.stringify(b)}`]
764
+ if (missing(b)) return [`${path}: ${JSON.stringify(a)} → 缺失`]
765
+ if (a === null || b === null) {
766
+ if (a === null && b === null) return []
767
+ return a === null ? [`${path}: 空值 → ${JSON.stringify(b)}`] : [`${path}: ${JSON.stringify(a)} → 空值`]
768
+ }
769
+ if (typeof a === 'number' && typeof b === 'string') return [`${path}: 类型变化(数字 ${JSON.stringify(a)} → 字符串 ${JSON.stringify(b)})`]
770
+ if (typeof a === 'string' && typeof b === 'number') return [`${path}: 类型变化(字符串 ${JSON.stringify(a)} → 数字 ${JSON.stringify(b)})`]
771
+ if (typeof a !== typeof b) return [`${path}: 类型变化(${typeof a} ${JSON.stringify(a)} → ${typeof b} ${JSON.stringify(b)})`]
772
+
773
+ if (Array.isArray(a) || Array.isArray(b)) {
774
+ if (!Array.isArray(a) || !Array.isArray(b)) return [`${path}: 类型变化(数组 ↔ 非数组)`]
775
+ const lines = []
776
+ const length = Math.max(a.length, b.length)
777
+ for (let index = 0; index < length; index += 1) {
778
+ if (index >= b.length) lines.push(`${path}[${index}]: ${JSON.stringify(a[index])} → 缺失(新侧没有这个下标)`)
779
+ else if (index >= a.length) lines.push(`${path}[${index}]: 缺失 → ${JSON.stringify(b[index])}`)
780
+ else lines.push(...diffJson(a[index], b[index], `${path}[${index}]`))
781
+ }
782
+ return lines
783
+ }
784
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
785
+ const lines = []
786
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
787
+ const child = path === '' ? key : `${path}.${key}`
788
+ if (!(key in b)) lines.push(`${child}: ${JSON.stringify(a[key])} → 缺失`)
789
+ else if (!(key in a)) lines.push(`${child}: 缺失 → ${JSON.stringify(b[key])}`)
790
+ else lines.push(...diffJson(a[key], b[key], child))
791
+ }
792
+ return lines
793
+ }
794
+ if (a === b) return []
795
+ if (isEmptyValue(a)) return [`${path}: 空值 → ${JSON.stringify(b)}`]
796
+ if (isEmptyValue(b)) return [`${path}: ${JSON.stringify(a)} → 空值`]
797
+ return [`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`]
798
+ }
799
+
800
+ // ─────────────────────────────────────────────────────────────────────────────
801
+ // §B6 授权闸门
802
+ // ─────────────────────────────────────────────────────────────────────────────
803
+
804
+ /**
805
+ * 授权表:**登记的是"哪些动作永远可用",其余一律受控**(fail-closed)。
806
+ *
807
+ * 之前这张表是反过来的(登记"哪些动作受控",没登记的动作放行),于是加一个新动作
808
+ * —— 或由调用方送一个空动作 —— 就等于**悄悄开了个口子**。三条默认值现在都是"关":
809
+ * 1. 工具不在表里 ⇒ 受控(新加一个 `bx_*` 忘了登记,是门关着,不是门开着);
810
+ * 2. 动作不在该工具的 `readOnly` 里 ⇒ 受控(含空动作与拼错的动作);
811
+ * 3. 只有显式写明 `actionless: true` 的工具(没有 action 参数、且只读)才不受这道门管。
812
+ *
813
+ * `readOnly` 里的动作必须来自各工具 `parameters.action` 真正声明的词表,多写一个不存在的
814
+ * 动作等于给一个不存在的入口开门(门禁第 6 条按词表逐项两向断)。
815
+ */
816
+ const GATE_TABLE = {
817
+ // 起 / 停 / 重启本地拦截代理:只有 status 是只读。
818
+ bx_proxy: { readOnly: ['status'] },
819
+ // 开关 macOS 系统代理:只有 status 是只读。
820
+ bx_sysproxy: { readOnly: ['status'] },
821
+ // 主动探测(发 HTTP/HTTPS/openssl 请求出去):整件受控,没有只读动作。
822
+ bx_doctor: { readOnly: [] },
823
+ // 只读:看 CA 信任态、打印给人复制的那条 sudo 命令、security verify-cert 校验。
824
+ bx_ca: { readOnly: ['status', 'command', 'verify'] },
825
+ // 只读:离线读 mitmproxy dump。
826
+ bx_flows: { readOnly: ['hosts', 'list', 'show', 'grep'] },
827
+ // 没有 action 参数,只读两份 JSON。
828
+ bx_diff: { actionless: true },
829
+ }
830
+
831
+ function gateReason(tool, action) {
832
+ const known = Object.hasOwn(GATE_TABLE, tool)
833
+ return `fly-blackbox: \`${tool}\` 的 \`${action ?? ''}\` 会改变本机状态(起代理 / 主动探测 / 动系统代理),`
834
+ + '属于**改动路径**,必须显式授权:设 FLY_ALLOW_INTERCEPT=1 再调用。'
835
+ + '只读路径(bx_proxy status、bx_sysproxy status、bx_ca *、bx_flows *、bx_diff)不受这道门管。'
836
+ + (known ? '' : `(\`${tool}\` 没有在授权表 GATE_TABLE 里登记,按 fail-closed 处理:登记它之前一律受控。)`)
837
+ }
838
+
839
+ /**
840
+ * 读这次调用的 `action`。
841
+ *
842
+ * **字段名是接缝的一部分**:引擎交给 `tools/pre-execute` 的 execution 由
843
+ * `@deepseek-ai/dsh-tools` 的 `createExecution()` 造出来,那个对象只有
844
+ * `token / callId / rootCallId / name / signal / agent? / parent? / schema? / arguments / …`
845
+ * —— **没有 `args`**。本文件曾读 `exec.args?.action`,于是 action 恒为 `undefined`,
846
+ * `bx_proxy start|stop|restart` 与 `bx_sysproxy on|off` 在未设 FLY_ALLOW_INTERCEPT 时
847
+ * **根本不进这道门**:门存在、门禁绿、门不关。`scripts/verify-blackbox.mjs` 第 6 条现在
848
+ * 按引擎真形状(`arguments`)喂这个 guard,并另有一条静态断言禁止这里再出现 `exec.args`。
849
+ */
850
+ function actionOf(exec) {
851
+ const action = exec.arguments?.action
852
+ return typeof action === 'string' ? action : undefined
853
+ }
854
+
855
+ function isGated(tool, action) {
856
+ const row = GATE_TABLE[tool]
857
+ if (row === undefined) return true
858
+ if (row.actionless === true) return false
859
+ return !row.readOnly.includes(action ?? '')
860
+ }
861
+
862
+ // ─────────────────────────────────────────────────────────────────────────────
863
+ // skill provider(照 dsh-skill-office 的形状)
864
+ // ─────────────────────────────────────────────────────────────────────────────
865
+
866
+ function skillDescription(raw, path) {
867
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw)
868
+ if (frontmatter?.[1] === undefined) throw new Error(`fly-blackbox: ${path} has no YAML frontmatter`)
869
+ // 只取 description(frontmatter 只有 name/description 两个键,见 SKILL.md)。
870
+ const description = /^description:\s*(.+)$/mu.exec(frontmatter[1])?.[1]?.trim()
871
+ if (description === undefined || description === '') throw new Error(`fly-blackbox: ${path} has no description`)
872
+ return { description, content: raw.slice(frontmatter[0].length).trim() }
873
+ }
874
+
875
+ function blackboxSkillProvider() {
876
+ const locator = join(ASSET_ROOT, 'SKILL.md')
877
+ const { description } = skillDescription(readFileSync(locator, 'utf8'), locator)
878
+ const candidate = {
879
+ name: 'client-blackbox',
880
+ description,
881
+ invocation: { modelInvocable: true, userInvocable: true },
882
+ provider: 'fly-blackbox',
883
+ source: 'bundled',
884
+ rank: BUNDLED_SKILL_RANK,
885
+ resourceBase: { kind: 'directory', path: ASSET_ROOT },
886
+ locator,
887
+ }
888
+ return {
889
+ name: 'fly-blackbox',
890
+ list: () => Promise.resolve([candidate]),
891
+ async get(requested, options) {
892
+ const { rank: _rank, locator: file, ...summary } = requested
893
+ const raw = readFileSync(file, { encoding: 'utf8', signal: options?.signal })
894
+ return { ...summary, content: skillDescription(raw, file).content }
895
+ },
896
+ }
897
+ }
898
+
899
+ // ─────────────────────────────────────────────────────────────────────────────
900
+ // tools
901
+ // ─────────────────────────────────────────────────────────────────────────────
902
+
903
+ /** 每个工具的统一输出形状(字符串 + 渲染成一行文本)。 */
904
+ const stringOutput = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }
905
+
906
+ export function apply(ctx) {
907
+ const shell = ctx.shell
908
+
909
+ // **先 stat 再注册**:缺任一资产就抛,宁可整棵 preset 被拒(日志可见),也不要静默少一个 skill。
910
+ for (const relative of ASSETS) {
911
+ const path = join(ASSET_ROOT, relative)
912
+ try {
913
+ if (!statSync(path).isFile()) throw new Error('not a file')
914
+ } catch {
915
+ throw new Error(`fly-blackbox: 自带资产缺失 ${relative}(期望在 ${path})—— files 里少一行就会这样,整棵 preset 拒绝挂载。`)
916
+ }
917
+ }
918
+ ctx.skills.registerProvider(() => blackboxSkillProvider())
919
+
920
+ // ── §B6 授权 guard ────────────────────────────────────────────────────────
921
+ //
922
+ // `actionOf()` 读的是 `exec.arguments`(引擎真形状),不是 `exec.args`。这里**不许**再
923
+ // 出现 `exec.args`:读错字段会让整个 guard 恒不触达,而门禁仍然全绿(见 actionOf 的注释)。
924
+ ctx.on('tools/pre-execute', async (exec, next) => {
925
+ if (exec.name?.startsWith('bx_')) {
926
+ const action = actionOf(exec)
927
+ if (isGated(exec.name, action) && process.env.FLY_ALLOW_INTERCEPT !== '1') {
928
+ return { kind: 'deny', reason: gateReason(exec.name, action) }
929
+ }
930
+ }
931
+ return next()
932
+ })
933
+
934
+ // ── bx_proxy ─────────────────────────────────────────────────────────────
935
+ ctx.tools.register(defineTool({
936
+ name: 'bx_proxy',
937
+ description: 'Start/stop/inspect the local mitmproxy interception proxy (status is read-only; start/stop/restart need FLY_ALLOW_INTERCEPT=1).',
938
+ parameters: {
939
+ action: { type: 'string', required: true, description: 'start | stop | status | restart' },
940
+ port: { type: 'number', description: 'listen port, default FLY_PROXY_PORT or 8080' },
941
+ rules: { type: 'string', description: 'rules.json path, default <workspace>/.fly-blackbox/rules.json' },
942
+ },
943
+ output: stringOutput,
944
+ async execute(args, exec) {
945
+ const root = workspaceRoot(exec)
946
+ const action = String(args.action ?? '')
947
+ const port = args.port ?? Number(process.env.FLY_PROXY_PORT ?? 8080)
948
+ const state = readState(root)
949
+
950
+ if (action === 'status') {
951
+ const status = await proxyStatusLines(shell, exec, root)
952
+ const lines = [`state: ${describeState(status.state)}`]
953
+ const version = await runCmd(shell, [mitmBin(), '--version'], exec, { timeoutMs: 20_000 })
954
+ if (version.exitCode !== 0) {
955
+ lines.push('mitmdump: 缺席', '', missingMitmdumpMessage(missingTool(version) ? `${mitmBin()}: command not found` : version.stderr.trim().split('\n')[0] ?? `exit ${version.exitCode}`))
956
+ return lines.join('\n')
957
+ }
958
+ lines.push(`mitmdump: ${version.stdout.trim().split('\n')[0]}`)
959
+ if (status.state === undefined) return lines.join('\n')
960
+ lines.push(`process alive (kill -0): ${status.alive ? 'yes' : 'no'}`)
961
+ lines.push(`port ${status.port} listeners: ${status.listeners.length === 0 ? '(none)' : status.listeners.join(' | ')}`)
962
+ if (status.stale) {
963
+ lines.push('', '⚠ 状态**陈旧**:进程存活与端口占用不一致 —— 不自动杀,请核对后清理状态文件:')
964
+ lines.push(` rm -f ${statePath(root)}`)
965
+ }
966
+ return lines.join('\n')
967
+ }
968
+
969
+ if (action === 'stop') {
970
+ if (state === undefined) return `没有状态文件,无需停止(${statePath(root)})`
971
+ const kill = await runCmd(shell, ['kill', String(state.pid)], exec, { timeoutMs: 15_000 })
972
+ const deadline = Date.now() + 5_000
973
+ let listeners = await listenersOn(shell, exec, state.port)
974
+ while (listeners.length > 0 && Date.now() < deadline) {
975
+ await new Promise(resolve => setTimeout(resolve, 250))
976
+ listeners = await listenersOn(shell, exec, state.port)
977
+ }
978
+ if (listeners.length > 0) {
979
+ return `PID ${state.pid} 已发信号(exit ${kill.exitCode}),但端口 ${state.port} 5 秒内未释放,状态文件保留:\n${listeners.join('\n')}`
980
+ }
981
+ clearState(root)
982
+ return `已停止 PID ${state.pid},端口 ${state.port} 已释放,状态文件已删除。`
983
+ }
984
+
985
+ if (action !== 'start' && action !== 'restart') {
986
+ return `unparsable action ${JSON.stringify(action)} — 只能是 start / stop / status / restart`
987
+ }
988
+
989
+ if (action === 'restart' && state !== undefined) {
990
+ const stop = await runCmd(shell, ['kill', String(state.pid)], exec, { timeoutMs: 15_000 })
991
+ const deadline = Date.now() + 5_000
992
+ let listeners = await listenersOn(shell, exec, state.port)
993
+ while (listeners.length > 0 && Date.now() < deadline) {
994
+ await new Promise(resolve => setTimeout(resolve, 250))
995
+ listeners = await listenersOn(shell, exec, state.port)
996
+ }
997
+ if (listeners.length > 0) {
998
+ throw new Error(`restart 拒绝继续:PID ${state.pid} 收到信号(exit ${stop.exitCode})后端口 ${state.port} 5 秒内未释放,先人工确认占用者`)
999
+ }
1000
+ clearState(root)
1001
+ }
1002
+
1003
+ if (action === 'start' && state !== undefined) {
1004
+ const status = await proxyStatusLines(shell, exec, root)
1005
+ if (status.alive) {
1006
+ throw new Error(`这个工作区已经有一个实例在跑:${describeState(state)}\n要重启请用 action=restart,或先 action=stop。`)
1007
+ }
1008
+ }
1009
+
1010
+ const version = await mitmdumpVersion(shell, exec)
1011
+ const rules = rulesPath(root, args.rules)
1012
+ const rulesText = await readTextFileVia(shell, exec, rules, '规则文件', { allowEmpty: true })
1013
+ const verdict = validateRules(rulesText.toString('utf8'), rules)
1014
+ if (!verdict.ok) throw new Error(`${verdict.reason}\n规则文件由 agent/用户用文件工具写(schema 见 skill 资产 reference/rules.schema.md);bx_proxy start 只读并校验。`)
1015
+
1016
+ const listeners = await listenersOn(shell, exec, port)
1017
+ if (listeners.length > 0) {
1018
+ throw new Error(`端口 ${port} 已被占用,拒绝启动(不是我们自己的实例):\n${listeners.join('\n')}`)
1019
+ }
1020
+
1021
+ const confdir = join(root, '.fly-blackbox', 'mitmproxy')
1022
+ const dump = join(root, '.fly-blackbox', 'flows.mitm')
1023
+ const addon = join(ASSET_ROOT, 'code', 'mitm_addon_template.py')
1024
+ // `onExpiry:'none'` **必须显式传**:`dsh-bash-local` 的 resolve() 缺省是
1025
+ // `onExpiry: request.onExpiry ?? "kill"`、`timeoutMs` 缺省 120000 —— 不 await result()
1026
+ // 只让调用立即返回,进程仍会在 120 秒被引擎杀掉。timeoutMs 一并显式传,免得读代码的人
1027
+ // 以为默认值无所谓。
1028
+ // 用 `startCmd`(**不 await result**):代理是常驻进程,await result 会挂到它退出。
1029
+ //
1030
+ // 规则路径与授权开关**只走环境变量,不走 `--set`**。实测(真 mitmdump 12.2.3):
1031
+ // `--set` 的值要等 addon 的 `load()` **之后**才写进 `ctx.options`(`load()` 里读
1032
+ // 到的是空串、`running()` 里才是给定值),而本 addon 的纪律是"规则文件缺失/非法
1033
+ // = 在 `load()` 里拒绝启动"。所以 `--set fly_rules=…` 曾经**静默无效**:addon
1034
+ // 退回默认的 `rules.json`、当场抛 `RuleError`、mitmdump 退出 1 —— 整条"起代理"
1035
+ // 的路都走不通,而只验证命令形状的接缝桩看不见这一点(真 mitmdump 的端到端才看见)。
1036
+ // 环境变量在 addon 的 import 期就读得到,所以只有它能撑住 `load()` 里的校验。
1037
+ mkdirSync(join(root, '.fly-blackbox'), { recursive: true })
1038
+ const started = await startCmd(shell, [
1039
+ mitmBin(),
1040
+ '-s', addon,
1041
+ '-w', dump,
1042
+ '--set', `confdir=${confdir}`,
1043
+ '-p', String(port),
1044
+ ], exec, {
1045
+ onExpiry: 'none',
1046
+ timeoutMs: 600_000,
1047
+ env: {
1048
+ MITM_RULES: rules,
1049
+ MITM_CAPTURE: join(root, '.fly-blackbox', 'capture'),
1050
+ FLY_ALLOW_INTERCEPT: process.env.FLY_ALLOW_INTERCEPT === '1' ? '1' : '0',
1051
+ },
1052
+ })
1053
+ if (started.handle.status === 'completed' && started.handle.exitCode === 0 && started.peek().trim() === '') {
1054
+ throw new Error(`mitmdump 退出码 0 且没有任何输出,拒绝写状态文件`)
1055
+ }
1056
+
1057
+ // 等就绪:最多 20 × 0.25s(原会话"3 次端口自旋 + 4 次重启"要的就是这个原语)。
1058
+ let ready = false
1059
+ for (let attempt = 0; attempt < 20 && !ready; attempt += 1) {
1060
+ await new Promise(resolve => setTimeout(resolve, 250))
1061
+ ready = (await listenersOn(shell, exec, port)).length > 0
1062
+ }
1063
+
1064
+ const pidRun = await runCmd(shell, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], exec, { timeoutMs: 20_000 })
1065
+ const pid = Number.parseInt(pidRun.stdout.trim().split('\n').filter(Boolean)[0] ?? '', 10)
1066
+ if (!Number.isInteger(pid)) {
1067
+ const early = started.peek().trim().slice(-2000)
1068
+ const state = started.handle.status === 'running' ? '还在跑但没等到 LISTEN' : `${started.handle.status}(exit ${String(started.handle.exitCode)})`
1069
+ throw new Error(`mitmdump ${state},${port} 上没有可识别的 LISTEN 进程,拒绝写状态文件。${early === '' ? '' : `\n${early}`}`)
1070
+ }
1071
+ const written = writeState(root, {
1072
+ pid,
1073
+ port,
1074
+ confdir,
1075
+ dump,
1076
+ rules,
1077
+ mitmdumpVersion: version.split('\n')[0],
1078
+ startedAt: new Date().toISOString(),
1079
+ })
1080
+
1081
+ return [
1082
+ `已启动:PID ${pid} · port ${port} · 就绪=${ready ? 'yes' : 'no(20×0.25s 内没看到 LISTEN)'}`,
1083
+ `confdir: ${confdir}`,
1084
+ `dump: ${dump}`,
1085
+ `rules: ${rules}(${verdict.summary})`,
1086
+ `mitmdump --version: ${version.replace(/\n/gu, ' | ')}`,
1087
+ `状态文件: ${written}`,
1088
+ '',
1089
+ '下一步:`bx_doctor` 判管道(第②段会核对 HTTPS 签发者是不是 confdir 里的 mitm CA)。',
1090
+ ].join('\n')
1091
+ },
1092
+ }))
1093
+
1094
+ // ── bx_doctor ────────────────────────────────────────────────────────────
1095
+ ctx.tools.register(defineTool({
1096
+ name: 'bx_doctor',
1097
+ description: 'Judge the interception pipeline in five legs (HTTP, HTTPS issuer == confdir mitm CA, client traffic, conflicting proxies, system proxy). Needs FLY_ALLOW_INTERCEPT=1.',
1098
+ parameters: {
1099
+ host: { type: 'string', description: 'target host to probe, default example.com' },
1100
+ port: { type: 'number', description: 'proxy port, default 8080' },
1101
+ },
1102
+ output: stringOutput,
1103
+ async execute(args, exec) {
1104
+ const root = workspaceRoot(exec)
1105
+ const state = readState(root)
1106
+ const port = args.port ?? state?.port ?? Number(process.env.FLY_PROXY_PORT ?? 8080)
1107
+ const host = args.host ?? 'example.com'
1108
+ const confdir = state?.confdir ?? join(root, '.fly-blackbox', 'mitmproxy')
1109
+ const lines = [`bx_doctor · proxy 127.0.0.1:${port} · 目标 ${host}`]
1110
+ let failed = 0
1111
+ const fail = (text, next) => {
1112
+ failed += 1
1113
+ lines.push(`FAIL ${text}`)
1114
+ if (next !== undefined) lines.push(` next: ${next}`)
1115
+ }
1116
+
1117
+ const version = await runCmd(shell, [mitmBin(), '--version'], exec, { timeoutMs: 20_000 })
1118
+ if (version.exitCode !== 0) {
1119
+ lines.push('mitmdump: 缺席 —— **本机没有抓包能力**(一等状态,不是"我没查出来")')
1120
+ lines.push(missingMitmdumpMessage(missingTool(version) ? `${mitmBin()}: command not found` : version.stderr.trim().split('\n')[0] ?? `exit ${version.exitCode}`))
1121
+ lines.push('')
1122
+ lines.push('(没有 mitmproxy 时 TLS 拦截没有纯仓内替代:只转发 CONNECT 的代理看得到域名、看不到 body,而这类缺陷恰恰在 body 里。要么装上它,要么以后做原生代理——不做"半个代理"。)')
1123
+ return lines.join('\n')
1124
+ }
1125
+ lines.push(`mitmdump: ${version.stdout.trim().split('\n')[0]}`)
1126
+ lines.push('')
1127
+
1128
+ // ① HTTP 过代理
1129
+ const http = await runCmd(shell, ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '-x', `http://127.0.0.1:${port}`, `http://${host}/`], exec, { timeoutMs: 30_000 })
1130
+ if (http.exitCode === 0 && http.stdout.trim() === '200') lines.push(`ok ① HTTP 过代理:200`)
1131
+ else fail(`① HTTP 过代理:${http.stdout.trim() || 'no answer'}(curl exit ${http.exitCode})`, '代理没起来?先 `bx_proxy status`,再看 `bx_proxy start` 的回显。')
1132
+
1133
+ // ② HTTPS 的签发者必须是 confdir 里的 mitm CA —— 只看 200 会被"别的代理/直连"骗过去
1134
+ const caPem = join(confdir, 'mitmproxy-ca-cert.pem')
1135
+ const https = await runCmd(shell, ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '-x', `http://127.0.0.1:${port}`, '--cacert', caPem, `https://${host}/`], exec, { timeoutMs: 30_000 })
1136
+ const issuer = await runCmd(shell, ['openssl', 's_client', '-connect', `${host}:443`, '-proxy', `127.0.0.1:${port}`, '-servername', host, '-showcerts'], exec, { timeoutMs: 30_000 })
1137
+ const issuerText = issuer.exitCode === 0 ? issuer.stdout : ''
1138
+ const caText = await runCmd(shell, ['openssl', 'x509', '-in', caPem, '-noout', '-subject', '-fingerprint', '-sha256'], exec, { timeoutMs: 30_000 })
1139
+ const caSubject = caText.exitCode === 0 ? (caText.stdout.match(/subject=.*/u)?.[0] ?? '').trim() : ''
1140
+ const seenIssuer = /issuer=.*/u.exec(issuerText)?.[0]?.trim() ?? ''
1141
+ if (https.exitCode === 0 && https.stdout.trim() === '200' && caSubject !== '' && /mitmproxy/iu.test(issuerText)) {
1142
+ lines.push(`ok ② HTTPS 过代理且证书链里是 mitm CA(curl ${https.stdout.trim()};${seenIssuer === '' ? caSubject : seenIssuer})`)
1143
+ } else {
1144
+ fail(
1145
+ `② HTTPS 的第②条校验没过:curl=${https.stdout.trim() || 'no answer'} · confdir CA=${caSubject === '' ? `读不到 ${caPem}` : caSubject} · 链里看到的 issuer=${seenIssuer === '' ? '(没拿到)' : seenIssuer}`,
1146
+ `只看 200 不算过:要确认签发者**就是** ${confdir} 里的 mitm CA。CA 不存在 → 先 'bx_ca status';证书链里没有 mitmproxy → 目标有 pinning 或用别的代理。`,
1147
+ )
1148
+ }
1149
+
1150
+ // ③ 目标客户端是否出现(dump 里的 [NEW HOST] 计数来自 addon 的 all.log;用 dump 的 host 集合代替)
1151
+ const dump = state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm')
1152
+ const dumpRun = await runCmd(shell, ['stat', '-f%z', dump], exec, { timeoutMs: 15_000 })
1153
+ if (dumpRun.exitCode !== 0) {
1154
+ lines.push('info ③ 还没有 dump 文件 —— 目标客户端一次都没走过这条代理。')
1155
+ lines.push(' 注意:微信小程序(WeChatAppEx)**只在启动时读系统代理**,改完系统代理必须 ⌘Q 再开。')
1156
+ } else {
1157
+ const hosts = await runCmd(shell, ['strings', dump], exec, { timeoutMs: 20_000, stdoutMaxBytes: 8 * 1024 * 1024 })
1158
+ const unique = new Set((hosts.stdout.match(/[a-z0-9-]+(?:\.[a-z0-9-]+)+/giu) ?? []).map(hit => hit.toLowerCase()))
1159
+ lines.push(`ok ③ dump 里出现过 ${unique.size} 个域名样本(dump ${dumpRun.stdout.trim()} 字节)`)
1160
+ if (unique.size === 0) lines.push(' ⚠ 一个域名都没解析出来:可能只抓到握手,或客户端根本没走代理。')
1161
+ }
1162
+
1163
+ // ④ 冲突代理(Clash / Surge / mihomo 常驻 7890/7897/8888)+ 占用我们端口者
1164
+ const conflicts = []
1165
+ for (const candidate of [7890, 7897, 8888]) {
1166
+ for (const line of await listenersOn(shell, exec, candidate)) conflicts.push(`${candidate}: ${line}`)
1167
+ }
1168
+ for (const line of await listenersOn(shell, exec, port)) conflicts.push(`${port}: ${line}`)
1169
+ if (conflicts.length === 0) lines.push('ok ④ 7890/7897/8888 与我们的端口都没有别的 LISTEN 者')
1170
+ else {
1171
+ lines.push(`info ④ 本机在跑的代理进程:`)
1172
+ for (const line of conflicts) lines.push(` ${line}`)
1173
+ lines.push(' 它们可能抢流量。确认系统代理指向我们之后,再看 ③ 有没有目标客户端的域名。')
1174
+ }
1175
+
1176
+ // ⑤ 系统代理:真实状态是 `Enabled`,不是 `Server`(本机现存残留正好是反例)
1177
+ const scutil = await runCmd(shell, ['scutil', '--proxy'], exec, { timeoutMs: 20_000 })
1178
+ if (scutil.exitCode !== 0) {
1179
+ lines.push('info ⑤ 读不到系统代理(scutil 缺席或非 macOS)')
1180
+ } else {
1181
+ const httpEnabled = /HTTPEnable\s*:\s*(\d)/u.exec(scutil.stdout)?.[1]
1182
+ const httpsEnabled = /HTTPSEnable\s*:\s*(\d)/u.exec(scutil.stdout)?.[1]
1183
+ const server = /HTTPSProxy\s*:\s*(\S+)/u.exec(scutil.stdout)?.[1] ?? '(none)'
1184
+ const enabled = httpEnabled === '1' || httpsEnabled === '1'
1185
+ if (enabled) lines.push(`ok ⑤ 系统代理 Enabled(HTTP=${httpEnabled}, HTTPS=${httpsEnabled}, Server=${server})`)
1186
+ else lines.push(`FAIL ⑤ 系统代理没开:Enabled 字段是 HTTP=${httpEnabled ?? '?'}/HTTPS=${httpsEnabled ?? '?'}(注意 Server 字段可能是残留的 ${server},**它不代表生效**)`)
1187
+ if (!enabled) lines.push(' next: `bx_sysproxy on` 之后 **⌘Q 那个客户端再开**(WeChatAppEx 只在启动时读系统代理)。')
1188
+ }
1189
+
1190
+ const rules = state?.rules ?? join(root, '.fly-blackbox', 'rules.json')
1191
+ const rulesRun = await runCmd(shell, ['base64', '-i', rules], exec, { timeoutMs: 20_000 })
1192
+ if (rulesRun.exitCode !== 0) lines.push(`info 规则文件:none(${rules})`)
1193
+ else {
1194
+ const verdict = validateRules(decodeBase64(rulesRun.stdout, '规则文件', rules, { allowEmpty: true }).toString('utf8'), rules)
1195
+ lines.push(verdict.ok ? `ok 规则文件:valid(${verdict.summary})` : `FAIL 规则文件:invalid —— ${verdict.reason}`)
1196
+ if (!verdict.ok) failed += 1
1197
+ }
1198
+
1199
+ lines.push('')
1200
+ lines.push(failed === 0 ? '判决:管道自检通过(这不等于"已经验证过真实拦截成功"——它证明的是管道通)。' : `判决:${failed} 条没过。`)
1201
+ return lines.join('\n')
1202
+ },
1203
+ }))
1204
+
1205
+ // ── bx_sysproxy ──────────────────────────────────────────────────────────
1206
+ ctx.tools.register(defineTool({
1207
+ name: 'bx_sysproxy',
1208
+ description: 'Inspect/point the macOS system proxy at the local interception proxy (on/off need FLY_ALLOW_INTERCEPT=1; always prints the one command that restores it).',
1209
+ parameters: {
1210
+ action: { type: 'string', required: true, description: 'status | on | off' },
1211
+ port: { type: 'number', description: 'proxy port, default FLY_PROXY_PORT or 8080' },
1212
+ },
1213
+ output: stringOutput,
1214
+ async execute(args, exec) {
1215
+ const action = String(args.action ?? '')
1216
+ const state = readState(workspaceRoot(exec))
1217
+ const port = args.port ?? state?.port ?? Number(process.env.FLY_PROXY_PORT ?? 8080)
1218
+
1219
+ const route = await runCmd(shell, ['sh', '-c', "route -n get default 2>/dev/null | awk '/interface:/{print $2}'"], exec, { timeoutMs: 20_000 })
1220
+ const iface = route.stdout.trim()
1221
+ const order = await runCmd(shell, ['sh', '-c', "networksetup -listnetworkserviceorder 2>/dev/null"], exec, { timeoutMs: 20_000 })
1222
+ let service = ''
1223
+ if (iface !== '') {
1224
+ // `(Hardware Port: Wi-Fi, Device: en0)` 是 networksetup 的成对形状;按 Device 命中。
1225
+ const block = new RegExp(`\\(\\d+\\)\\s+([^\\n]*)\\n\\(Hardware Port: ([^,]+), Device: ${iface.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\)`, 'u').exec(order.stdout)
1226
+ service = block?.[1]?.trim() ?? ''
1227
+ }
1228
+ if (service === '') {
1229
+ throw new Error(
1230
+ `解析不出默认路由的网络服务名(默认接口 ${iface === '' ? '(route 没给出)' : iface})—— 绝不拿空名去发 networksetup 命令。`
1231
+ + '\n请人工执行 `networksetup -listnetworkserviceorder` 确认服务名,再用 `bx_sysproxy` 之外的方式设置,或回报本机的服务名形状。',
1232
+ )
1233
+ }
1234
+ const restore = `networksetup -setwebproxystate ${shq(service)} off && networksetup -setsecurewebproxystate ${shq(service)} off`
1235
+
1236
+ if (action === 'status') {
1237
+ const current = await runCmd(shell, ['scutil', '--proxy'], exec, { timeoutMs: 20_000 })
1238
+ const httpEnabled = /HTTPEnable\s*:\s*(\d)/u.exec(current.stdout)?.[1] ?? '?'
1239
+ const httpsEnabled = /HTTPSEnable\s*:\s*(\d)/u.exec(current.stdout)?.[1] ?? '?'
1240
+ return [
1241
+ `默认路由服务:${service}(接口 ${iface})`,
1242
+ `系统代理(真实状态看 Enabled):HTTPEnable=${httpEnabled} · HTTPSEnable=${httpsEnabled}`,
1243
+ `还原命令(一条):${restore}`,
1244
+ ].join('\n')
1245
+ }
1246
+ if (action !== 'on' && action !== 'off') return `unparsable action ${JSON.stringify(action)} — 只能是 status / on / off`
1247
+
1248
+ const commands = action === 'on'
1249
+ ? [
1250
+ `networksetup -setwebproxy ${shq(service)} 127.0.0.1 ${port}`,
1251
+ `networksetup -setsecurewebproxy ${shq(service)} 127.0.0.1 ${port}`,
1252
+ ]
1253
+ : [
1254
+ `networksetup -setwebproxystate ${shq(service)} off`,
1255
+ `networksetup -setsecurewebproxystate ${shq(service)} off`,
1256
+ ]
1257
+ for (const command of commands) requireOk(await runCmd(shell, ['sh', '-c', command], exec, { timeoutMs: 30_000 }), command)
1258
+ const after = await runCmd(shell, ['scutil', '--proxy'], exec, { timeoutMs: 20_000 })
1259
+ const line = action === 'on'
1260
+ ? `已把系统代理指向 127.0.0.1:${port}(服务 ${service})`
1261
+ : `已关掉系统代理(服务 ${service})`
1262
+ return [
1263
+ line,
1264
+ `随手核对的 Enabled 字段:${/HTTPEnable\s*:\s*\d/u.exec(after.stdout)?.[0] ?? '(读不到)'} · ${/HTTPSEnable\s*:\s*\d/u.exec(after.stdout)?.[0] ?? '(读不到)'}`,
1265
+ `还原命令(一条,随时可复制):${restore}`,
1266
+ action === 'on' ? '下一步:改完系统代理要 **⌘Q 那个客户端再开**(WeChatAppEx 只在启动时读系统代理),然后 `bx_doctor`。' : '',
1267
+ ].filter(Boolean).join('\n')
1268
+ },
1269
+ }))
1270
+
1271
+ // ── bx_ca ────────────────────────────────────────────────────────────────
1272
+ ctx.tools.register(defineTool({
1273
+ name: 'bx_ca',
1274
+ description: 'Inspect the mitm CA trust state and print (never run) the command a human must execute; verify afterwards with security verify-cert. Never escalates privileges.',
1275
+ parameters: {
1276
+ action: { type: 'string', required: true, description: 'status | command | verify' },
1277
+ },
1278
+ output: stringOutput,
1279
+ async execute(args, exec) {
1280
+ const root = workspaceRoot(exec)
1281
+ const state = readState(root)
1282
+ const confdir = state?.confdir ?? join(root, '.fly-blackbox', 'mitmproxy')
1283
+ const caPem = join(confdir, 'mitmproxy-ca-cert.pem')
1284
+ const action = String(args.action ?? '')
1285
+
1286
+ const fingerprint = await runCmd(shell, ['openssl', 'x509', '-in', caPem, '-noout', '-sha256', '-fingerprint'], exec, { timeoutMs: 20_000 })
1287
+ if (fingerprint.exitCode !== 0) {
1288
+ return [
1289
+ `读不到 mitm CA:${caPem}`,
1290
+ 'CA 由 mitmdump 首次启动时生成 —— 先 `bx_proxy start`(它会回显 confdir 绝对路径),再回来。',
1291
+ ].join('\n')
1292
+ }
1293
+ const fp = fingerprint.stdout.trim().split('\n').map(l => l.trim()).filter(Boolean).join(' | ')
1294
+ const trust = await runCmd(shell, ['security', 'verify-cert', '-c', caPem], exec, { timeoutMs: 30_000 })
1295
+ const trusted = trust.exitCode === 0
1296
+ const trustLine = trusted
1297
+ ? `可信:security verify-cert -c ${caPem} 退出 0`
1298
+ : `不可信:security verify-cert -c ${caPem} 退出 ${trust.exitCode} —— ${trust.stderr.trim().split('\n').slice(-2).join(' | ')}`
1299
+
1300
+ if (action === 'status') {
1301
+ return [
1302
+ `CA: ${caPem}`,
1303
+ `指纹: ${fp}`,
1304
+ trustLine,
1305
+ trusted ? '' : '\n要人跑的那条命令用 `bx_ca command` 取(本包**永不提权**,所以不经接缝执行)。',
1306
+ ].filter(Boolean).join('\n')
1307
+ }
1308
+
1309
+ if (action === 'command') {
1310
+ // 这条带 `sudo` 的命令是**给人复制的文本**,不经过接缝。理由见文件头纪律三。
1311
+ return [
1312
+ '# 下面这条命令要你自己在终端跑(本包永不提权:沙箱里提权调用弹不出框、也没有超时,真会话会挂住)',
1313
+ `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${caPem}`,
1314
+ '',
1315
+ `# 只用当前用户(不需要 sudo,但只对当前用户生效):`,
1316
+ `security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ${caPem}`,
1317
+ '',
1318
+ `# 跑完用 bx_ca verify 取事后证据(security verify-cert 的退出码 + 指纹)。`,
1319
+ `# 还原:sudo security delete-certificate -c "mitmproxy" /Library/Keychains/System.keychain`,
1320
+ ].join('\n')
1321
+ }
1322
+
1323
+ if (action === 'verify') {
1324
+ return [
1325
+ `证据:security verify-cert -c ${caPem} → ${trusted ? '退出 0(可信)' : `退出 ${trust.exitCode}(仍不可信)`}`,
1326
+ `指纹(应与上面的命令装的是同一张):${fp}`,
1327
+ trust.stderr.trim() === '' ? '' : `stderr: ${trust.stderr.trim().split('\n').slice(-2).join(' | ')}`,
1328
+ '',
1329
+ `提示:装完 CA 后目标客户端要**重启**才会读新的信任设置(小程序宿主尤其如此)。`,
1330
+ ].filter(Boolean).join('\n')
1331
+ }
1332
+ return `unparsable action ${JSON.stringify(action)} — 只能是 status / command / verify`
1333
+ },
1334
+ }))
1335
+
1336
+ // ── bx_flows ─────────────────────────────────────────────────────────────
1337
+ ctx.tools.register(defineTool({
1338
+ name: 'bx_flows',
1339
+ description: 'Read a mitmproxy dump offline (hosts | list | show | grep). Reads through the shell seam, refuses truncated output, and gates on the flow format version.',
1340
+ parameters: {
1341
+ action: { type: 'string', required: true, description: 'hosts | list | show | grep' },
1342
+ path: { type: 'string', description: 'dump path, default <workspace>/.fly-blackbox/flows.mitm' },
1343
+ pattern: { type: 'string', description: 'grep pattern (regex, case-insensitive)' },
1344
+ index: { type: 'number', description: 'flow index for show, default 0' },
1345
+ limit: { type: 'number', description: 'max rows for list/grep, default 50' },
1346
+ json: { type: 'boolean', description: 'machine JSON output (list/show)' },
1347
+ },
1348
+ output: stringOutput,
1349
+ async execute(args, exec) {
1350
+ const root = workspaceRoot(exec)
1351
+ const state = readState(root)
1352
+ const action = String(args.action ?? '')
1353
+ const dump = args.path ?? state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm')
1354
+
1355
+ const size = await statSizeVia(shell, exec, dump, 'dump')
1356
+ if (size > 32 * 1024 * 1024) {
1357
+ throw new Error(
1358
+ `dump 有 ${(size / 1024 / 1024).toFixed(1)}MB,超过 32MB 上限,拒绝读(读它要走 base64,重了解不动)。\n`
1359
+ + `修法:先用 mitmdump 过滤一遍再存,例如\n`
1360
+ + ` mitmdump -nr ${dump} -w ${dump.replace(/\.mitm$/u, '')}-filtered.mitm "${'~u example.com'}"\n`
1361
+ + `或直接用 bx_flows 的 grep 定位目标域名后,只导出那段。`,
1362
+ )
1363
+ }
1364
+ const buffer = await readTextFileVia(shell, exec, dump, 'dump')
1365
+ const version = await runCmd(shell, [mitmBin(), '--version'], exec, { timeoutMs: 20_000 })
1366
+ const mitmdumpVersion = version.exitCode === 0 ? version.stdout.trim().split('\n')[0] : undefined
1367
+ const read = readFlows(buffer)
1368
+ for (const flow of read.flows) assertReadableVersion(flow, { path: dump, mitmdumpVersion })
1369
+ const flows = read.flows.map((flow, index) => summarizeFlow(flow, index))
1370
+ const limit = args.limit ?? 50
1371
+ const note = read.truncated ? `⚠ ${read.note};下面的结果是**已完整读到的部分**,不是全量。` : ''
1372
+
1373
+ if (action === 'hosts') {
1374
+ const counts = new Map()
1375
+ for (const flow of flows) counts.set(flow.host, (counts.get(flow.host) ?? 0) + 1)
1376
+ const rows = [...counts].sort((left, right) => right[1] - left[1])
1377
+ const body = args.json === true
1378
+ ? JSON.stringify({ hosts: rows.map(([host, count]) => ({ host, count })), total: flows.length }, null, 2)
1379
+ : [`${rows.length} 个域名 / ${flows.length} 条 flow`, ...rows.map(([host, count]) => `${String(count).padStart(5)} ${host}`)].join('\n')
1380
+ return [note, body].filter(Boolean).join('\n')
1381
+ }
1382
+ if (action === 'list') {
1383
+ const shown = flows.slice(0, limit)
1384
+ const body = args.json === true
1385
+ ? JSON.stringify({ total: flows.length, shown: shown.length, flows: shown.map(flow => ({ index: flow.index, method: flow.method, status: flow.status, host: flow.host, path: flow.path, bytes: flow.requestBody.bytes + flow.responseBody.bytes })) }, null, 2)
1386
+ : shown.map(flow => listLine(flow)).join('\n')
1387
+ const rest = flows.length - shown.length
1388
+ return [note, body, rest > 0 ? `还有 ${rest} 条未显示(limit=${limit})` : ''].filter(Boolean).join('\n')
1389
+ }
1390
+ if (action === 'show') {
1391
+ const index = args.index ?? 0
1392
+ const flow = flows[index]
1393
+ if (flow === undefined) throw new Error(`没有第 ${index} 条 flow(这个 dump 有 ${flows.length} 条)`)
1394
+ return [note, args.json === true ? JSON.stringify(flow, null, 2) : showText(flow)].filter(Boolean).join('\n')
1395
+ }
1396
+ if (action === 'grep') {
1397
+ const pattern = args.pattern
1398
+ if (typeof pattern !== 'string' || pattern === '') throw new Error('grep 需要 pattern(正则,大小写不敏感)')
1399
+ let matcher
1400
+ try {
1401
+ matcher = new RegExp(pattern, 'iu')
1402
+ } catch (error) {
1403
+ throw new Error(`grep 的 pattern 不是合法正则:${error.message}`)
1404
+ }
1405
+ const hits = flows.filter(flow => grepText(flow, matcher.source))
1406
+ const shown = hits.slice(0, limit)
1407
+ return [
1408
+ note,
1409
+ `${hits.length} 条命中 ${JSON.stringify(pattern)}`,
1410
+ ...shown.map(flow => showText(flow)),
1411
+ hits.length - shown.length > 0 ? `还有 ${hits.length - shown.length} 条未显示(limit=${limit})` : '',
1412
+ ].filter(Boolean).join('\n')
1413
+ }
1414
+ return `unparsable action ${JSON.stringify(action)} — 只能是 hosts / list / show / grep`
1415
+ },
1416
+ }))
1417
+
1418
+ // ── bx_diff ──────────────────────────────────────────────────────────────
1419
+ ctx.tools.register(defineTool({
1420
+ name: 'bx_diff',
1421
+ description: 'Field-level JSON diff for before/after change audits: distinguishes 缺失 vs 假值 vs 类型变化.',
1422
+ parameters: {
1423
+ a: { type: 'string', required: true, description: 'old JSON file path' },
1424
+ b: { type: 'string', required: true, description: 'new JSON file path' },
1425
+ fields: { type: 'string', description: 'comma list of top-level fields to compare (default: all)' },
1426
+ },
1427
+ output: stringOutput,
1428
+ async execute(args, exec) {
1429
+ const readJson = async (path, what) => {
1430
+ const text = (await readTextFileVia(shell, exec, path, what)).toString('utf8')
1431
+ try {
1432
+ return JSON.parse(text)
1433
+ } catch (error) {
1434
+ throw new Error(`${what} 不是合法 JSON:${path} —— ${error.message}`)
1435
+ }
1436
+ }
1437
+ const a = await readJson(args.a, 'a')
1438
+ const b = await readJson(args.b, 'b')
1439
+ if (typeof a !== 'object' || a === null || Array.isArray(a)) throw new Error(`a 必须是 JSON 对象:${args.a}`)
1440
+ if (typeof b !== 'object' || b === null || Array.isArray(b)) throw new Error(`b 必须是 JSON 对象:${args.b}`)
1441
+ const only = typeof args.fields === 'string' && args.fields.trim() !== '' ? args.fields.split(',').map(field => field.trim()).filter(Boolean) : null
1442
+ const lines = []
1443
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
1444
+ if (only !== null && !only.includes(key)) continue
1445
+ if (!(key in b)) lines.push(`${key}: ${JSON.stringify(a[key])} → 缺失`)
1446
+ else if (!(key in a)) lines.push(`${key}: 缺失 → ${JSON.stringify(b[key])}`)
1447
+ else lines.push(...diffJson(a[key], b[key], key))
1448
+ }
1449
+ if (lines.length === 0) return '无差异(两份 JSON 在这些字段上逐值相同)'
1450
+ return [`${lines.length} 处差异:`, ...lines.map(line => ` ${line}`)].join('\n')
1451
+ },
1452
+ }))
1453
+ }