@dsh-fly/dsh-plugin-fly-mode 0.1.4 → 0.1.6

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.
@@ -0,0 +1,931 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+
3
+ /**
4
+ * fly-artifact — 打包产物的**静态重建**(规划 §3.2,P1 能力线第一条)。
5
+ *
6
+ * 两个工具,两层:
7
+ *
8
+ * 1. `artifact_asar_inventory`(清单层):只读 asar 头(8 字节 pickle 前缀 + header JSON),
9
+ * 给 header 布局、条目数与大小、`package.json` 的入口(`main`)、关键成员(`*.node` /
10
+ * `package.json` / main 指向的那个文件)与整档 sha256。**不解包、不执行、不落盘**。
11
+ * 2. `artifact_js_boundaries`(边界层):对一棵解包后的 JS 树**或** asar 里的 `.js` 成员做
12
+ * **字面量**扫描,抽 fetch / XHR / WebSocket / URL 字面量与 Electron IPC 通道名,
13
+ * 每条带 **file:line**,去重后有界(`top`),并**显式报覆盖率**(扫了几个、跳过几个、为什么)。
14
+ *
15
+ * 三条纪律:
16
+ *
17
+ * · **读文件一律走 `ctx.shell` 接缝**:`od` 取字节区间、`stat`/`base64` 取整份小文件、
18
+ * `shasum` 取摘要 —— 沙箱拒绝会以 `SandboxError` 报出,而不是一个裸 EACCES。本包
19
+ * **不 import `node:fs`**(门禁有一条静态钉子盯着这件事)。为什么必须按**区间**读:
20
+ * 真实 Electron 应用的 app.asar 是 150MB 量级(本机 DSH Fly 自己那份 158,976,491 字节,
21
+ * 光 header JSON 就 4,418,662 字节)—— 整份 base64 回来的那一天就是"重到解不动"。
22
+ * · **不做数据流**:这是字面量扫描,不是 AST。字符串拼接、变量别名、压缩后的名字一律看不见;
23
+ * 每一条这样的盲区都写在结果的 `limitations` 里,绝不留成"看起来扫全了"。
24
+ * · **省略绝不藏起来**:任何受 `top` 限界的列表都给 `bounds`(shown / total / omitted),
25
+ * 顶层 `truncated` 汇总;覆盖率里"跳过的文件"逐条点名(含原因),不静默丢。
26
+ *
27
+ * ASAR 布局(本机**实测解码**,不是回忆 —— 见 `scripts/verify-artifact-tools.mjs` 的
28
+ * "真实 Electron 应用的 app.asar" 那条断言):
29
+ *
30
+ * 偏移 0 u32 恒为 4:第一个 pickle 的长度字段
31
+ * 偏移 4 u32 headerBlockBytes —— 下面整块 pickle 的字节数(= 4 + payloadBytes)
32
+ * 偏移 8 u32 payloadBytes = 4 + jsonBytes + 补到 4 字节对齐的 0..3 字节
33
+ * 偏移 12 u32 jsonBytes(header JSON 的字节数)
34
+ * 偏移 16 … header JSON
35
+ * 内容基址 = 8 + headerBlockBytes(成员条目里的 `offset` 相对它)
36
+ *
37
+ * `@electron/asar` 的读法是"读 8 字节 → 取 u32 → 再从偏移 8 读整块",与上表同一件事。
38
+ * 本包不引 `@electron/asar`(新增依赖要用户点头,而这张表已经把需要的全说了),按表自己读。
39
+ *
40
+ * 两个入口都在 P1 的"结论要能回指源码位置"那条要求下:清单的每条成员给**相对路径**,
41
+ * 边界的每条命中给 **file:line**。
42
+ */
43
+
44
+ export const name = 'fly-artifact'
45
+ export const inject = ['tools', 'shell']
46
+
47
+ function shq(value) {
48
+ return `'${String(value).replace(/'/g, "'\\''")}'`
49
+ }
50
+
51
+ /**
52
+ * Run one confined command through the host `shell` seam.
53
+ *
54
+ * `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
55
+ * 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
56
+ * projection carries the run result. An earlier revision of this file awaited a
57
+ * nonexistent `shell.run()`, so every shell-backed tool threw
58
+ * `TypeError: shell.run is not a function` on every call, in every engine
59
+ * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
60
+ * names a seam method the executor does not have.
61
+ */
62
+ async function runCmd(shell, argv, exec, opts = {}) {
63
+ const result = await (await shell.execute(shell.resolve({
64
+ command: argv.map(shq).join(' '),
65
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
66
+ ...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
67
+ signal: exec.signal,
68
+ }))).result()
69
+ if (result.aborted) {
70
+ const error = new Error('tool call aborted')
71
+ error.name = 'AbortError'
72
+ throw error
73
+ }
74
+ if (result.sandbox?.denied) {
75
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
76
+ error.name = 'SandboxError'
77
+ throw error
78
+ }
79
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
80
+ }
81
+
82
+ /**
83
+ * A failed command must never come back as an empty success.
84
+ *
85
+ * Exit 127 (and the shell's "command not found") gets its own wording, because
86
+ * macOS ships no ELF toolchain: `readelf`, `checksec`, `ROPgadget` and `nmap`
87
+ * are simply absent. The old code turned that into "no output", which read
88
+ * exactly like "nothing found".
89
+ */
90
+ function requireOk(run, what) {
91
+ if (run.exitCode === 0) return run.stdout
92
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
93
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
94
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
95
+ error.name = 'CommandError'
96
+ throw error
97
+ }
98
+
99
+ // ── 常量 ────────────────────────────────────────────────────────────────────
100
+
101
+ /** 任何受 `top` 限界的列表的缺省上限。 */
102
+ const DEFAULT_TOP = 50
103
+ /** header JSON 超过它就拒读:实测最大的真实档头是 4,418,662 字节(本机 DSH Fly 自己那份),8MB 已是它的两倍。 */
104
+ const ASAR_MAX_HEADER_BYTES = 8 * 1024 * 1024
105
+ /** 条目数硬闸:畸形档不许把整条工具拖死。 */
106
+ const ASAR_MAX_ENTRIES = 500_000
107
+ /** 视为"源码"的扩展名。 */
108
+ const JS_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx']
109
+ /** 单文件超过它**整份跳过**(读半份再假装扫过,等于把省略藏起来)。 */
110
+ const JS_MAX_FILE_BYTES = 256 * 1024
111
+ /** 本次调用读进内存的总预算。 */
112
+ const JS_MAX_TOTAL_BYTES = 4 * 1024 * 1024
113
+ /** 文件数上限。 */
114
+ const JS_MAX_FILES = 2000
115
+ /** 每条命中最多带几个位置(`occurrences` 仍是真值)。 */
116
+ const MAX_SITES_PER_ROW = 5
117
+
118
+ /** 工具输出形状:结构化 JSON 文本(与 `bx_har` 同一形状,模型与人都能读)。 */
119
+ const stringOutput = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }
120
+
121
+ // ── 接缝读取:区间 / 整份 / 摘要 ─────────────────────────────────────────────
122
+
123
+ /** 错误文案里的最后一行 stderr(没有就 undefined)。 */
124
+ function lastLine(text) {
125
+ const line = String(text ?? '').trim().split('\n').at(-1)?.trim() ?? ''
126
+ return line === '' ? undefined : line
127
+ }
128
+
129
+ /**
130
+ * 读一段字节(经接缝,`od`)—— 与 `fly-binary` 的 `elfReadRange` 同一条路、同一个理由:
131
+ * 读**区间**而不是整份,才不会为了看一个 4KB 成员把 150MB 拖进来。
132
+ *
133
+ * `od` 拒绝从 EOF 起跳("cannot skip past end of input",exit 1)—— 那是**短读**,不是失败:
134
+ * 被截断的档要给"这里没有内容",而不是把整条工具打挂。解码长度由调用方按真值再核一次。
135
+ *
136
+ * **配额不是拍脑袋的**:`od -tx1` 每字节吐 3 个字符,而 `-An` 只是把地址列涂白、宽度还在
137
+ * —— 实测 4,418,662 字节 → 20,436,359 字符(4.63 字符/字节)。第一版按 4 倍给,于是真实档的
138
+ * 头被 stdoutMaxBytes 悄悄截掉 13%,报出去的却是"档被截断了"(一个假结论,A/B 之外自己踩到)。
139
+ */
140
+ async function readRange(shell, exec, path, offset, length) {
141
+ const budget = Math.max(65536, Math.min(64 * 1024 * 1024, length * 6 + 65536))
142
+ const run = await runCmd(
143
+ shell,
144
+ ['od', '-An', '-v', '-tx1', '-j', String(offset), '-N', String(length), path],
145
+ exec,
146
+ { stdoutMaxBytes: budget },
147
+ )
148
+ if (run.exitCode !== 0 && !/cannot skip|past end of input/iu.test(run.stderr)) requireOk(run, `od -j ${offset} -N ${length}`)
149
+ const hex = run.stdout.replace(/\s+/gu, '')
150
+ const bytes = Buffer.from(hex.slice(0, hex.length - (hex.length % 2)), 'hex')
151
+ if (bytes.length < length && run.stdout.length >= budget) {
152
+ throw new Error(`${path} 的 ${offset}..${offset + length} 读不动:od 的输出撞上 ${budget} 字节的读取配额(本工具的上限,不是档的问题)`)
153
+ }
154
+ return bytes
155
+ }
156
+
157
+ /**
158
+ * 文件字节数:`stat -f%z`(BSD/macOS,与 `bx_har` 同一条路),非 BSD 上退回 `wc -c`(GNU)。
159
+ *
160
+ * **先量再读**:读之前就知道"这个太大",才不会为了拒掉一个 200MB 的档先把那一次读取付掉。
161
+ */
162
+ async function fileSize(shell, exec, path, what) {
163
+ const stat = await runCmd(shell, ['stat', '-f%z', path], exec, { timeoutMs: 15_000 })
164
+ if (stat.exitCode === 0) {
165
+ const last = stat.stdout.trim().split('\n').at(-1)?.trim() ?? ''
166
+ if (/^\d+$/u.test(last)) return Number(last)
167
+ }
168
+ const wc = await runCmd(shell, ['wc', '-c', path], exec, { timeoutMs: 15_000 })
169
+ const first = wc.stdout.trim().split(/\s+/u)[0] ?? ''
170
+ if (wc.exitCode !== 0 || !/^\d+$/u.test(first)) {
171
+ throw new Error(
172
+ `${what} 不存在或读不到:${path} —— stat 说 ${JSON.stringify(lastLine(stat.stderr) ?? `exit ${stat.exitCode}`)},`
173
+ + `wc 说 ${JSON.stringify(lastLine(wc.stderr) ?? `exit ${wc.exitCode}`)}`,
174
+ )
175
+ }
176
+ return Number(first)
177
+ }
178
+
179
+ /** 整档 sha256(经接缝调本机 `shasum -a 256`;GNU coreutils 的 `sha256sum` 作后备)。 */
180
+ async function fileSha256(shell, exec, path, what) {
181
+ const attempts = [['shasum', '-a', '256', path], ['sha256sum', path]]
182
+ const reasons = []
183
+ for (const argv of attempts) {
184
+ const run = await runCmd(shell, argv, exec, { timeoutMs: 300_000 })
185
+ const hex = run.stdout.trim().split(/\s+/u)[0] ?? ''
186
+ if (run.exitCode === 0 && /^[0-9a-f]{64}$/u.test(hex)) return { sha256: hex, method: argv.join(' ') }
187
+ reasons.push(`${argv[0]}: ${lastLine(run.stderr) ?? `exit ${run.exitCode}`}`)
188
+ }
189
+ throw new Error(`${what} 的 sha256 算不出来:${path} —— ${reasons.join(';')}`)
190
+ }
191
+
192
+ /**
193
+ * 整份读一个**小**文件(经接缝 `base64 -i`)—— 与 `bx_har` 的读取同一条路。
194
+ * 超过 `maxBytes` 就不读,返回 `tooLarge`(由调用方点名记进覆盖率)。
195
+ */
196
+ async function readSmallFile(shell, exec, path, what, maxBytes) {
197
+ const size = await fileSize(shell, exec, path, what)
198
+ if (size > maxBytes) return { tooLarge: true, size }
199
+ const run = await runCmd(shell, ['base64', '-i', path], exec, { stdoutMaxBytes: maxBytes * 2 + 65536 })
200
+ if (run.exitCode !== 0) throw new Error(`${what} 读不到:${path} —— ${lastLine(run.stderr) ?? `exit ${run.exitCode}`}`)
201
+ // macOS/Linux 的 `base64` 默认在 stdout 里插换行,解码前一律剔除空白。
202
+ const compact = run.stdout.replace(/\s+/gu, '')
203
+ return { tooLarge: false, size, bytes: Buffer.from(compact, 'base64') }
204
+ }
205
+
206
+ // ── ASAR 读取与清单 ─────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * 读 asar 头。**先核 pickle 不变量再信里面的数字**:畸形档(比如把别的文件改名叫 `.asar`)
210
+ * 必须在这一步被拒,而不是拿着 `jsonBytes = 0x7fffffff` 去 od 一个天大的区间。
211
+ */
212
+ async function readAsar(shell, exec, path) {
213
+ const bytes = await fileSize(shell, exec, path, 'ASAR')
214
+ if (bytes < 16) throw new Error(`${path} 只有 ${bytes} 字节,装不下 asar 的 16 字节 pickle 头 —— 这不是一个 ASAR`)
215
+ const head = await readRange(shell, exec, path, 0, 16)
216
+ if (head.length < 16) throw new Error(`${path} 的头只读到 ${head.length}/16 字节 —— 档被截断了`)
217
+ const sizeField = head.readUInt32LE(0)
218
+ const headerBlockBytes = head.readUInt32LE(4)
219
+ const payloadBytes = head.readUInt32LE(8)
220
+ const jsonBytes = head.readUInt32LE(12)
221
+ const paddingBytes = payloadBytes - 4 - jsonBytes
222
+ const problems = []
223
+ if (sizeField !== 4) problems.push(`第一个长度字段是 ${sizeField}(asar/pickle 恒为 4)`)
224
+ if (jsonBytes <= 0) problems.push(`header JSON 长度是 ${jsonBytes}`)
225
+ else if (jsonBytes > ASAR_MAX_HEADER_BYTES) {
226
+ problems.push(`header JSON ${(jsonBytes / 1048576).toFixed(1)}MB 超过 ${ASAR_MAX_HEADER_BYTES / 1048576}MB 上限`)
227
+ }
228
+ if (paddingBytes < 0 || paddingBytes > 3) problems.push(`payload(${payloadBytes}) − 4 − json(${jsonBytes}) = ${paddingBytes},不在 0..3 的对齐填充范围里`)
229
+ if (headerBlockBytes !== 4 + payloadBytes) problems.push(`headerBlock(${headerBlockBytes}) ≠ 4 + payload(${payloadBytes})`)
230
+ if (problems.length > 0) {
231
+ throw new Error(
232
+ `${path} 不像一个 ASAR:${problems.join(';')}。\n`
233
+ + '修法:确认路径指的是 Electron 打包出来的 `.asar`(未打包的应用目录请直接给那个目录,'
234
+ + '`artifact_js_boundaries` 会按解包后的 JS 树扫)。',
235
+ )
236
+ }
237
+ const jsonBuf = await readRange(shell, exec, path, 16, jsonBytes)
238
+ if (jsonBuf.length !== jsonBytes) throw new Error(`${path} 的 header JSON 只读到 ${jsonBuf.length}/${jsonBytes} 字节 —— 档被截断了`)
239
+ let header
240
+ try {
241
+ header = JSON.parse(jsonBuf.toString('utf8'))
242
+ } catch (error) {
243
+ throw new Error(`${path} 的 header JSON 解析失败:${error.message}(头 80 字节:${JSON.stringify(jsonBuf.toString('utf8', 0, 80))})`)
244
+ }
245
+ if (header === null || typeof header !== 'object' || Array.isArray(header) || header.files === undefined || header.files === null) {
246
+ throw new Error(`${path} 的 header 里没有 \`files\` 映射 —— 这不是 asar 的根节点`)
247
+ }
248
+ return {
249
+ bytes,
250
+ header,
251
+ contentBase: 8 + headerBlockBytes,
252
+ format: {
253
+ kind: 'asar',
254
+ sizeField,
255
+ headerBlockBytes,
256
+ payloadBytes,
257
+ jsonBytes,
258
+ paddingBytes,
259
+ contentBase: 8 + headerBlockBytes,
260
+ headerKeys: Object.keys(header).sort(),
261
+ },
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 把 header 的嵌套 `files` 树摊平成条目表。
267
+ *
268
+ * 条目的 `offset` **可能是字符串**(实测:本机 DSH Fly 自己那份 app.asar 里 `"offset":"154557515"`,
269
+ * 而 `size` 是数字)—— 两处都必须按数字再用,不然 `base + offset` 会变成字符串拼接(一个
270
+ * 静默读到别处的错值)。`unpacked: true` 的成员**没有 offset**:它的内容在
271
+ * `<档名>.asar.unpacked/` 里,头里只有声明大小。
272
+ */
273
+ function asarMembers(header) {
274
+ const files = []
275
+ const directories = []
276
+ const walk = (node, prefix, depth) => {
277
+ if (depth > 64) return
278
+ for (const [name, entry] of Object.entries(node.files ?? {})) {
279
+ const memberPath = `${prefix}${name}`
280
+ if (entry !== null && typeof entry === 'object' && entry.files !== undefined) {
281
+ directories.push(memberPath)
282
+ walk(entry, `${memberPath}/`, depth + 1)
283
+ continue
284
+ }
285
+ if (files.length >= ASAR_MAX_ENTRIES) throw new Error(`asar 条目超过 ${ASAR_MAX_ENTRIES} 条,拒绝继续摊平(档畸形或超出本工具的设计面)`)
286
+ const size = Number(entry?.size ?? 0)
287
+ files.push({
288
+ path: memberPath,
289
+ size: Number.isFinite(size) ? size : 0,
290
+ offset: entry?.offset === undefined || entry?.offset === null ? null : Number(entry.offset),
291
+ unpacked: entry?.unpacked === true,
292
+ executable: entry?.executable === true,
293
+ integrity: typeof entry?.integrity?.hash === 'string' ? entry.integrity.hash : null,
294
+ })
295
+ }
296
+ }
297
+ walk(header, '', 0)
298
+ files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
299
+ directories.sort()
300
+ return { files, directories }
301
+ }
302
+
303
+ /** 按 `top` 切一刀,把"切掉了几个"一起返回(省略绝不藏起来)。 */
304
+ function bound(rows, top) {
305
+ const shown = rows.slice(0, top)
306
+ return { rows: shown, bounds: { shown: shown.length, total: rows.length, omitted: rows.length - shown.length } }
307
+ }
308
+
309
+ function readTop(value, tool) {
310
+ const top = value === undefined ? DEFAULT_TOP : value
311
+ if (typeof top !== 'number' || !Number.isInteger(top) || top < 1) {
312
+ throw new Error(`${tool} 的 top 必须是 ≥1 的整数:给了 ${JSON.stringify(value)}`)
313
+ }
314
+ return top
315
+ }
316
+
317
+ /** 扩展名(小写,含点)。 */
318
+ function extensionOf(path) {
319
+ const base = path.slice(path.lastIndexOf('/') + 1)
320
+ const dot = base.lastIndexOf('.')
321
+ return dot <= 0 ? '' : base.slice(dot).toLowerCase()
322
+ }
323
+
324
+ /** `main` 的写法归一:`./out/main.js` 与 `/out/main.js` 都指向 `out/main.js`。 */
325
+ function normalizeMemberPath(path) {
326
+ let out = String(path).trim()
327
+ while (out.startsWith('./')) out = out.slice(2)
328
+ while (out.startsWith('/')) out = out.slice(1)
329
+ return out
330
+ }
331
+
332
+ /**
333
+ * 这份清单**看不见什么** —— 照 §5 的最小证据形状,逐条写给模型(也写给以后改这个工具的人)。
334
+ */
335
+ function asarLimitations({ file, format, members, entry, top, truncated, sharedOffsetEntries }) {
336
+ const lines = [
337
+ '只读 asar 头(pickle 前缀 + header JSON),不解包、不执行、不落盘:成员**内容**没有被读过,'
338
+ + '条目里的 integrity 哈希是**原样报出**的声明值,本工具不重算。',
339
+ `sha256 是**整个档**的(经接缝调本机 ${file.sha256Method}),不是任何一个成员的内容摘要。`,
340
+ '成员大小取自 header 的声明(`size`),没有和盘上的实际内容核过;'
341
+ + '`unpacked: true` 的成员内容在 `<档名>.asar.unpacked/` 里,本工具不读那个目录。',
342
+ `成员大小合计(declaredTotal)**可以大于档体积**:asar 允许多条目录项共享同一段内容`
343
+ + `(本次 ${sharedOffsetEntries} 条目录项的 (offset,size) 与别的条目完全相同),`
344
+ + '另外本工具把 unpacked 成员的声明大小也计入了 declaredTotal,而它们不在档里。',
345
+ `关键成员按**名字与扩展名**分类(*.node / package.json / main 指向的那一个),不看内容:`
346
+ + `本次 ${members.files} 个文件条目里 *.node ${members.nativeAddons} 个、package.json ${members.packageJson} 个。`,
347
+ entry.found
348
+ ? `入口取自根 package.json 的 main(${JSON.stringify(entry.main)}),并在成员表里找到了它。`
349
+ : `**入口未知**:${entry.error ?? (entry.main === null ? '根的 package.json 里没有 main 字段' : `main=${JSON.stringify(entry.main)} 在成员表里找不到`)}`
350
+ + ' —— 这是"没读到",不是"没有入口"。',
351
+ `没有任何签名 / 公证(codesign / notarization)检查:这不是一个"档可不可信"的结论。`,
352
+ `header 的解析只覆盖上面那张 pickle 布局(本次 paddingBytes=${format.paddingBytes};`
353
+ + '实测真实档里 0 与 2 两种填充都出现过)。',
354
+ ]
355
+ if (truncated) lines.push(`输出被 top=${top} 截断:看每一段的 bounds(shown/total/omitted),这不是全量。`)
356
+ return lines
357
+ }
358
+
359
+ // ── JS 字面量扫描 ───────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * 一行里的**字符串字面量**与"把字符串和注释挖空、其余字符位置不变"的掩码。
363
+ *
364
+ * 为什么不是"先剔注释再正则":`https://…` 里的 `//` 与行注释同形,先剔注释会把一半的
365
+ * URL 从中间切掉(本工具第一版就是这么错的形状)。所以反过来:**先切字符串**,
366
+ * 注释只在字符串之外被认出来。
367
+ *
368
+ * 模板串只取 `${}` **之前**的静态前缀(`dynamic: true` 标出来):`${}` 里是代码,
369
+ * 拼出来的那一段是数据流问题,超出本工具的口径。
370
+ */
371
+ function lexLine(line, state) {
372
+ const tokens = []
373
+ let masked = ''
374
+ let i = 0
375
+ while (i < line.length) {
376
+ if (state.block) {
377
+ const end = line.indexOf('*/', i)
378
+ if (end === -1) {
379
+ masked += ' '.repeat(line.length - i)
380
+ i = line.length
381
+ continue
382
+ }
383
+ masked += ' '.repeat(end + 2 - i)
384
+ i = end + 2
385
+ state.block = false
386
+ continue
387
+ }
388
+ const ch = line[i]
389
+ if (ch === '/' && line[i + 1] === '*') {
390
+ state.block = true
391
+ masked += ' '
392
+ i += 2
393
+ continue
394
+ }
395
+ if (ch === '/' && line[i + 1] === '/') {
396
+ masked += ' '.repeat(line.length - i)
397
+ i = line.length
398
+ continue
399
+ }
400
+ if (ch === '"' || ch === "'" || ch === '`') {
401
+ const quote = ch
402
+ let j = i + 1
403
+ let raw = ''
404
+ while (j < line.length) {
405
+ const c = line[j]
406
+ if (c === '\\') {
407
+ raw += line.slice(j, j + 2)
408
+ j += 2
409
+ continue
410
+ }
411
+ if (c === quote) {
412
+ j += 1
413
+ break
414
+ }
415
+ raw += c
416
+ j += 1
417
+ }
418
+ const dynamic = quote === '`' && raw.includes('${')
419
+ const value = (dynamic ? raw.slice(0, raw.indexOf('${')) : raw).replace(/\\(["'`\\/])/gu, '$1')
420
+ tokens.push({ value, raw, quote, start: i, end: j, dynamic })
421
+ masked += ' '.repeat(j - i)
422
+ i = j
423
+ continue
424
+ }
425
+ masked += ch
426
+ i += 1
427
+ }
428
+ return { tokens, masked }
429
+ }
430
+
431
+ /**
432
+ * 已知的调用面。`arg` 是"第几个字符串字面量是它的目标"(从 0 开始),
433
+ * `arg: null` 表示这只是一处**位置证据**(例如 `new XMLHttpRequest` 本身没有字面量)。
434
+ *
435
+ * 这是一张**白名单**:不在表里的写法看不见(`got('x')` 这种自定义包装、压缩后的 `a.b("x")`),
436
+ * 这一条写在 `limitations` 里,不许当成"没有别的边界"。
437
+ */
438
+ const CALL_PATTERNS = [
439
+ { kind: 'fetch', re: /(?:^|[^\w$.])(?:window\s*\.\s*)?fetch\s*\(/gu, arg: 0 },
440
+ { kind: 'websocket', re: /new\s+WebSocket\s*\(/gu, arg: 0 },
441
+ { kind: 'eventsource', re: /new\s+EventSource\s*\(/gu, arg: 0 },
442
+ { kind: 'beacon', re: /navigator\s*\.\s*sendBeacon\s*\(/gu, arg: 0 },
443
+ // `.open(method, url)` 是 XHR 的经典形状,但别的 API 也叫 open —— 所以这一条**只认
444
+ // 第二个参数是显式 URL** 的写法(`requireUrl`)。代价是相对路径的 XHR 看不见,
445
+ // 那一条写在 limitations 里,不藏。
446
+ { kind: 'xhr-open', re: /\.\s*open\s*\(/gu, arg: 1, requireUrl: true },
447
+ { kind: 'xhr', re: /new\s+XMLHttpRequest\s*\(/gu, arg: null, marker: 'new XMLHttpRequest' },
448
+ { kind: 'ipc-main', re: /\bipcMain\s*\.\s*(?:handle|handleOnce|on|once|emit)\s*\(/gu, arg: 0 },
449
+ { kind: 'ipc-renderer', re: /\bipcRenderer\s*\.\s*(?:invoke|send|sendSync|sendTo|on|once|postMessage)\s*\(/gu, arg: 0 },
450
+ { kind: 'ipc-webcontents', re: /\bwebContents\s*\.\s*(?:send|invoke)\s*\(/gu, arg: 0 },
451
+ { kind: 'context-bridge', re: /\bcontextBridge\s*\.\s*exposeInMainWorld\s*\(/gu, arg: 0 },
452
+ ]
453
+
454
+ /**
455
+ * 调用的第 `wanted` 个字面量参数:从 `from` 往后找字符串记号,中间**只允许**
456
+ * 括号 / 逗号 / 空白 / 花括号方括号 —— 别的东西一出现就说明那不是同一个调用
457
+ * (这一条挡掉"同一行里前一个调用的字符串被算到后一个调用头上")。
458
+ */
459
+ function argumentToken(masked, tokens, from, wanted) {
460
+ let cursor = from
461
+ let seen = 0
462
+ for (const token of tokens) {
463
+ if (token.start < from) continue
464
+ if (!/^[\s(),{}[\]]*$/u.test(masked.slice(cursor, token.start))) return null
465
+ if (seen === wanted) return token
466
+ seen += 1
467
+ cursor = token.end
468
+ }
469
+ return null
470
+ }
471
+
472
+ /**
473
+ * 一个字符串字面量里的 URL(**去掉 query 与 fragment**:打包产物里的 query 常常带
474
+ * token / 签名,与 `bx_har` 同一条隐私口径 —— 那一段根本不复制进输出)。
475
+ * 只认**显式 scheme** 的写法(`https://`、`wss://`、`file://`…):协议相对地址
476
+ * (`//host/x`)与相对路径会作为普通字面量走 `fetch` 之类的 kind,不在这里猜。
477
+ */
478
+ function urlOf(value) {
479
+ const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^\s/?#]*)([^\s?#]*)/u.exec(value.trim())
480
+ if (match === null) return null
481
+ const scheme = match[1].toLowerCase()
482
+ const host = match[2]
483
+ const path = match[3]
484
+ const rest = value.trim().slice(match[0].length)
485
+ return {
486
+ scheme,
487
+ host,
488
+ path,
489
+ canonical: `${scheme}://${host}${path}`,
490
+ hadQuery: rest.startsWith('?') || rest.startsWith('#'),
491
+ }
492
+ }
493
+
494
+ /** 扫一份文本,把命中交给 `collect`。空行直接跳过(省一次词法)。 */
495
+ function scanText(text, file, collect) {
496
+ const state = { block: false }
497
+ const lines = text.split('\n')
498
+ for (const [index, rawLine] of lines.entries()) {
499
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
500
+ if (line.trim() === '') continue
501
+ const { tokens, masked } = lexLine(line, state)
502
+ const lineNumber = index + 1
503
+ const used = new Set()
504
+ for (const pattern of CALL_PATTERNS) {
505
+ for (const match of masked.matchAll(pattern.re)) {
506
+ if (pattern.arg === null) {
507
+ collect(pattern.kind, { value: pattern.marker, dynamic: false }, file, lineNumber)
508
+ continue
509
+ }
510
+ const token = argumentToken(masked, tokens, match.index + match[0].length, pattern.arg)
511
+ if (token === null) continue
512
+ if (pattern.requireUrl === true && urlOf(token.value) === null) continue
513
+ used.add(tokens.indexOf(token))
514
+ collect(pattern.kind, token, file, lineNumber)
515
+ }
516
+ }
517
+ for (const [i, token] of tokens.entries()) {
518
+ if (used.has(i)) continue
519
+ if (urlOf(token.value) === null) continue
520
+ collect('url-literal', token, file, lineNumber)
521
+ }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * 命中表:**去重键 = kind + 整条字面量**(不是 host、不是 path 前缀)。
527
+ *
528
+ * 为什么写死这一条:把键退化成 host 之后,"同一个域名的两个不同路径"会被折叠成一条,
529
+ * `occurrences` 反而变大 —— 一份看着更"干净"的端点数,掩盖了真实边界数(A/B 变异 A 就是这个形状)。
530
+ */
531
+ function createHitTable() {
532
+ const rows = new Map()
533
+ const byFile = new Map()
534
+ let sites = 0
535
+ let dynamicHits = 0
536
+ let queryStripped = 0
537
+ return {
538
+ collect(kind, token, file, line) {
539
+ const url = urlOf(token.value)
540
+ if (url !== null && url.hadQuery) queryStripped += 1
541
+ const key = `${kind}\u0000${token.value}`
542
+ let row = rows.get(key)
543
+ if (row === undefined) {
544
+ row = { kind, value: url === null ? token.value : url.canonical, occurrences: 0, dynamic: false, sites: [] }
545
+ rows.set(key, row)
546
+ }
547
+ row.occurrences += 1
548
+ sites += 1
549
+ if (token.dynamic) {
550
+ row.dynamic = true
551
+ dynamicHits += 1
552
+ }
553
+ if (!row.sites.some(site => site.file === file && site.line === line) && row.sites.length < MAX_SITES_PER_ROW) {
554
+ row.sites.push({ file, line })
555
+ }
556
+ byFile.set(file, (byFile.get(file) ?? 0) + 1)
557
+ },
558
+ result() {
559
+ const all = [...rows.values()].sort((left, right) => (left.kind === right.kind
560
+ ? (left.value < right.value ? -1 : left.value > right.value ? 1 : 0)
561
+ : (left.kind < right.kind ? -1 : 1)))
562
+ return {
563
+ rows: all,
564
+ endpoints: all.filter(row => row.kind !== 'ipc-main' && row.kind !== 'ipc-renderer' && row.kind !== 'ipc-webcontents' && row.kind !== 'context-bridge'),
565
+ channels: all.filter(row => row.kind === 'ipc-main' || row.kind === 'ipc-renderer' || row.kind === 'ipc-webcontents' || row.kind === 'context-bridge'),
566
+ filesWithHits: [...byFile.entries()].map(([file, hits]) => ({ file, hits })).sort((left, right) => (left.hits === right.hits ? (left.file < right.file ? -1 : 1) : right.hits - left.hits)),
567
+ sites,
568
+ dynamicHits,
569
+ queryStripped,
570
+ }
571
+ },
572
+ }
573
+ }
574
+
575
+ /**
576
+ * 这份边界扫描**看不见什么** —— 每条都要能对上实现里的一行,不许写成套话。
577
+ */
578
+ function jsLimitations({ mode, coverage, scope, top, truncated, hits, filesWithHits }) {
579
+ const lines = [
580
+ '**字面量扫描,不是 AST**:不做数据流、不追变量拼接、不解析 `require`/`import` 的别名。'
581
+ + '`const base = "https://x"; fetch(base + "/a")`、`const { invoke } = ipcRenderer; invoke("ch")`、'
582
+ + '压缩后的 `a.b("ch")` —— 这些一律看不见。',
583
+ '命令执行一律经 host 的 `shell` 接缝(读文件、列目录都是);本工具**不执行**被扫的代码、'
584
+ + '不发任何网络请求、不改本机状态。',
585
+ `识别的调用面是一张白名单(${CALL_PATTERNS.map(pattern => pattern.kind).join(' / ')}):`
586
+ + '表外的写法(自定义包装、动态属性名)看不见,所以"没看到 fetch"不等于"没有外联";'
587
+ + '其中 `xhr-open` 只认 `.open(method, <显式 URL>)` 的形状 —— 相对路径的 XHR 看不见。',
588
+ `模板字符串只取 \`\${}\` 之前的静态前缀(本次 ${hits.dynamicHits} 处命中带 \`\${}\`),`
589
+ + '拼接出来的那一段是数据流问题,本工具不猜。',
590
+ 'URL 一律**去掉 query 与 fragment** 再输出(打包产物里的 query 常带 token / 签名);'
591
+ + `本次有 ${hits.queryStripped} 处命中带 query 或 fragment,那一段没有被复制进输出。`,
592
+ `只有字符串字面量会被看:正则字面量里的 URL、注释里的 URL、运行时拼出来的 URL 都不算`
593
+ + '(注释里的样例 URL 被**故意**排除:它们是文档,不是边界)。',
594
+ `本次扫了 ${coverage.scanned} 个文件(${coverage.bytes.scanned} 字节),`
595
+ + `跳过 ${coverage.skipped.total} 个(${
596
+ Object.entries(coverage.skipped.reasons).filter(([, count]) => count > 0).map(([reason, count]) => `${reason} ${count}`).join(' / ') || '无'
597
+ })—— 跳过的文件在 coverage.skipped.files 里逐条点名。`,
598
+ coverage.skipped.reasons.tooLarge > 0 || coverage.skipped.reasons.budget > 0
599
+ ? '**跳过的文件是被整份跳过的**(不读半份再算扫过):单文件上限与总预算见 coverage.limits;'
600
+ + '要提高覆盖就把它们切开、或分几次调用(例如按目录分批)。'
601
+ : '没有文件因为大小或预算被跳过。',
602
+ scope === 'app'
603
+ ? `默认 scope=app:路径里含 \`node_modules/\` 的文件不进扫描(本次 ${coverage.skipped.reasons.nodeModules} 个,`
604
+ + '这是**策略性排除**、不逐条列文件,但数字一定报出来),所以"依赖里的外联"不在本次结果里;'
605
+ + '要连依赖一起扫请显式给 scope=all。'
606
+ : 'scope=all:依赖(node_modules)也在扫描面里,第三方库的外联会与本体代码混在一起。',
607
+ mode === 'tree' && coverage.bytes.candidate === null
608
+ ? '树模式下 bytes.candidate 是 null:候选文件的字节数要逐个 stat 才知道,本工具不为报一个总数去多跑 N 条命令(bytes.scanned 是真值)。'
609
+ : '',
610
+ mode === 'asar'
611
+ ? '扫描对象是 asar 里的**打包**成员(`unpacked: true` 的成员内容在 `.asar.unpacked/` 里,本次没有读)。'
612
+ : '扫描对象是盘上的解包 JS 树。',
613
+ `每条命中最多带 ${MAX_SITES_PER_ROW} 个位置(sites),\`occurrences\` 是真实命中次数。`,
614
+ filesWithHits.length === 0 ? '本次没有任何命中 —— 这是"在扫到的这些文件里没有",不是"这个产物没有边界"。' : '',
615
+ ]
616
+ if (truncated) lines.push(`输出被 top=${top} 截断:看每一段的 bounds(shown/total/omitted),这不是全量。`)
617
+ return lines.filter(line => line !== '')
618
+ }
619
+
620
+ // ── 工具面 ──────────────────────────────────────────────────────────────────
621
+
622
+ export function apply(ctx) {
623
+ const shell = ctx.shell
624
+
625
+ // ── artifact_asar_inventory ───────────────────────────────────────────────
626
+ ctx.tools.register(defineTool({
627
+ name: 'artifact_asar_inventory',
628
+ description: 'Inventory an ASAR archive (Electron app bundle): header layout, entry counts and sizes, the package.json main entry, key members (*.node, package.json, the main target) and a whole-archive sha256. Reads the header only — nothing is extracted or executed.',
629
+ parameters: {
630
+ path: { type: 'string', required: true, description: 'Absolute path to the .asar file' },
631
+ top: { type: 'number', description: 'max rows per list, default 50; anything omitted is counted under bounds' },
632
+ },
633
+ output: stringOutput,
634
+ async execute(args, exec) {
635
+ const path = typeof args.path === 'string' ? args.path.trim() : ''
636
+ if (path === '') throw new Error('artifact_asar_inventory 需要 path(.asar 的路径)')
637
+ const top = readTop(args.top, 'artifact_asar_inventory')
638
+
639
+ const archive = await readAsar(shell, exec, path)
640
+ const digest = await fileSha256(shell, exec, path, 'ASAR')
641
+ const { files, directories } = asarMembers(archive.header)
642
+
643
+ const rootPackageJson = files.find(member => member.path === 'package.json')
644
+ let declaredName = null
645
+ let declaredVersion = null
646
+ let declaredMain = null
647
+ let rootPackageJsonError = null
648
+ if (rootPackageJson === undefined) {
649
+ rootPackageJsonError = '根目录没有 package.json 成员'
650
+ } else if (rootPackageJson.unpacked || rootPackageJson.offset === null) {
651
+ rootPackageJsonError = '根的 package.json 是 unpacked 成员,内容不在档里(本工具不读 .asar.unpacked/)'
652
+ } else {
653
+ const size = Math.min(rootPackageJson.size, JS_MAX_FILE_BYTES)
654
+ if (rootPackageJson.size > JS_MAX_FILE_BYTES) {
655
+ rootPackageJsonError = `根的 package.json 有 ${rootPackageJson.size} 字节,超过 ${JS_MAX_FILE_BYTES} 字节的读取上限`
656
+ } else {
657
+ const bytes = await readRange(shell, exec, path, archive.contentBase + rootPackageJson.offset, size)
658
+ try {
659
+ const parsed = JSON.parse(bytes.toString('utf8'))
660
+ declaredName = typeof parsed.name === 'string' ? parsed.name : null
661
+ declaredVersion = typeof parsed.version === 'string' ? parsed.version : null
662
+ declaredMain = typeof parsed.main === 'string' ? parsed.main : null
663
+ } catch (error) {
664
+ rootPackageJsonError = `根的 package.json 解析失败:${error.message}`
665
+ }
666
+ }
667
+ }
668
+
669
+ const mainPath = declaredMain === null ? null : normalizeMemberPath(declaredMain)
670
+ const mainMember = mainPath === null ? undefined : files.find(member => member.path === mainPath)
671
+
672
+ const nativeAddons = files.filter(member => extensionOf(member.path) === '.node')
673
+ const nativeLibs = files.filter(member => ['.dylib', '.so', '.dll'].includes(extensionOf(member.path)))
674
+ const packageJson = files.filter(member => member.path.endsWith('package.json'))
675
+
676
+ const packed = files.filter(member => !member.unpacked && member.offset !== null)
677
+ const unpacked = files.filter(member => member.unpacked || member.offset === null)
678
+ const offsets = new Map()
679
+ for (const member of packed) {
680
+ const key = `${member.offset}:${member.size}`
681
+ offsets.set(key, (offsets.get(key) ?? 0) + 1)
682
+ }
683
+ const sharedEntries = [...offsets.values()].filter(count => count > 1).reduce((total, count) => total + count, 0)
684
+ const maxEnd = packed.reduce((end, member) => Math.max(end, member.offset + member.size), 0)
685
+ const declaredTotal = files.reduce((total, member) => total + member.size, 0)
686
+
687
+ const membersBounds = bound(files, top)
688
+ const nativeAddonsBounds = bound(nativeAddons, top)
689
+ const nativeLibsBounds = bound(nativeLibs, top)
690
+ const packageJsonBounds = bound(packageJson, top)
691
+ const mainBounds = bound(mainMember === undefined ? [] : [mainMember], top)
692
+
693
+ const result = {
694
+ tool: 'artifact_asar_inventory',
695
+ target: path,
696
+ operation: 'read an ASAR header and inventory its members (no extraction, no execution)',
697
+ file: { path, bytes: archive.bytes, sha256: digest.sha256, sha256Method: digest.method },
698
+ format: archive.format,
699
+ entry: {
700
+ name: declaredName,
701
+ version: declaredVersion,
702
+ main: declaredMain,
703
+ path: mainPath,
704
+ found: mainMember !== undefined,
705
+ member: mainMember === undefined ? null : { path: mainMember.path, size: mainMember.size, offset: mainMember.offset },
706
+ error: rootPackageJsonError,
707
+ },
708
+ members: {
709
+ files: files.length,
710
+ directories: directories.length,
711
+ unpacked: unpacked.length,
712
+ nativeAddons: nativeAddons.length,
713
+ packageJson: packageJson.length,
714
+ shown: membersBounds.rows.map(member => ({
715
+ path: member.path,
716
+ size: member.size,
717
+ offset: member.offset,
718
+ ...(member.unpacked ? { unpacked: true } : {}),
719
+ ...(member.executable ? { executable: true } : {}),
720
+ })),
721
+ },
722
+ keyMembers: {
723
+ nativeAddons: nativeAddonsBounds.rows.map(member => member.path),
724
+ nativeLibs: nativeLibsBounds.rows.map(member => member.path),
725
+ packageJson: packageJsonBounds.rows.map(member => member.path),
726
+ main: mainBounds.rows.map(member => member.path),
727
+ },
728
+ bytes: {
729
+ file: archive.bytes,
730
+ declaredTotal,
731
+ packed: packed.reduce((total, member) => total + member.size, 0),
732
+ unpacked: unpacked.reduce((total, member) => total + member.size, 0),
733
+ contentBase: archive.contentBase,
734
+ contentEnd: archive.contentBase + maxEnd,
735
+ unaccounted: archive.bytes - (archive.contentBase + maxEnd),
736
+ sharedOffsetEntries: sharedEntries,
737
+ },
738
+ top,
739
+ bounds: {
740
+ members: membersBounds.bounds,
741
+ nativeAddons: nativeAddonsBounds.bounds,
742
+ nativeLibs: nativeLibsBounds.bounds,
743
+ packageJson: packageJsonBounds.bounds,
744
+ main: mainBounds.bounds,
745
+ },
746
+ confidence: 'observed',
747
+ }
748
+ result.truncated = Object.values(result.bounds).some(entryBounds => entryBounds.omitted > 0)
749
+ result.limitations = asarLimitations({
750
+ file: result.file,
751
+ format: archive.format,
752
+ members: result.members,
753
+ entry: result.entry,
754
+ top,
755
+ truncated: result.truncated,
756
+ sharedOffsetEntries: sharedEntries,
757
+ })
758
+ return JSON.stringify(result, null, 2)
759
+ },
760
+ }))
761
+
762
+ // ── artifact_js_boundaries ────────────────────────────────────────────────
763
+ ctx.tools.register(defineTool({
764
+ name: 'artifact_js_boundaries',
765
+ description: 'Scan a JS tree or the JS members of an asar for literal network endpoints (fetch, XHR, WebSocket, URL literals) and Electron IPC channel names, each with file:line; reports how many files were scanned and which were skipped, and why. Literal-level only — no data flow.',
766
+ parameters: {
767
+ path: { type: 'string', required: true, description: 'Absolute path to an .asar file OR to an unpacked JS directory' },
768
+ top: { type: 'number', description: 'max rows per list, default 50; anything omitted is counted under bounds' },
769
+ scope: { type: 'string', description: 'app (default: skip node_modules/) | all' },
770
+ },
771
+ output: stringOutput,
772
+ async execute(args, exec) {
773
+ const path = typeof args.path === 'string' ? args.path.trim() : ''
774
+ if (path === '') throw new Error('artifact_js_boundaries 需要 path(.asar 的路径,或解包后的 JS 目录)')
775
+ const top = readTop(args.top, 'artifact_js_boundaries')
776
+ const scope = args.scope === undefined ? 'app' : args.scope
777
+ if (scope !== 'app' && scope !== 'all') throw new Error(`artifact_js_boundaries 的 scope 只能是 app / all:给了 ${JSON.stringify(args.scope)}`)
778
+
779
+ const probe = await runCmd(shell, ['find', path, '-maxdepth', '0', '-type', 'd'], exec, { timeoutMs: 15_000 })
780
+ if (probe.exitCode !== 0) {
781
+ throw new Error(`${path} 不存在或读不到:${lastLine(probe.stderr) ?? `exit ${probe.exitCode}`}`)
782
+ }
783
+ const isDirectory = probe.stdout.trim() !== ''
784
+
785
+ let mode
786
+ let archive = null
787
+ let memberIndex = new Map()
788
+ let list = []
789
+ let nonJs = 0
790
+ let unpackedJs = 0
791
+ let candidateBytes = null
792
+ if (isDirectory) {
793
+ mode = 'tree'
794
+ const listing = await runCmd(shell, ['find', path, '-type', 'f'], exec, { timeoutMs: 120_000, stdoutMaxBytes: 16 * 1024 * 1024 })
795
+ if (listing.exitCode !== 0) requireOk(listing, `find ${path} -type f`)
796
+ list = listing.stdout.split('\n').map(line => line.trim()).filter(line => line !== '').sort()
797
+ const candidates = list.filter(file => JS_EXTENSIONS.includes(extensionOf(file)))
798
+ nonJs = list.length - candidates.length
799
+ list = candidates
800
+ } else {
801
+ mode = 'asar'
802
+ try {
803
+ archive = await readAsar(shell, exec, path)
804
+ } catch (error) {
805
+ throw new Error(`${path} 既不是一个目录,也不像一个 ASAR:${error.message}`)
806
+ }
807
+ const { files } = asarMembers(archive.header)
808
+ memberIndex = new Map(files.map(member => [member.path, member]))
809
+ const js = files.filter(member => JS_EXTENSIONS.includes(extensionOf(member.path)))
810
+ nonJs = files.length - js.length
811
+ const scanable = js.filter(member => !member.unpacked && member.offset !== null)
812
+ unpackedJs = js.length - scanable.length
813
+ list = scanable.map(member => member.path).sort()
814
+ }
815
+
816
+ const dependency = file => file.includes('/node_modules/') || file.startsWith('node_modules/')
817
+ const inScope = scope === 'all' ? list : list.filter(file => !dependency(file))
818
+ if (mode === 'asar') candidateBytes = inScope.reduce((total, file) => total + (memberIndex.get(file)?.size ?? 0), 0)
819
+ const skipped = { tooLarge: [], budget: [], unreadable: [], nodeModules: scope === 'all' ? [] : list.filter(dependency) }
820
+ const hits = createHitTable()
821
+ let scanned = 0
822
+ let scannedBytes = 0
823
+ const rootPrefix = path.endsWith('/') ? path : `${path}/`
824
+ // 命中与"跳过的文件"用**同一套标签**:asar 模式是成员相对路径,树模式是相对根目录的路径。
825
+ // 两种标签混着用会让"这条命中出自哪个文件"和"跳过了哪个文件"对不上号。
826
+ const labelOf = file => (mode === 'asar' ? file : (file.startsWith(rootPrefix) ? file.slice(rootPrefix.length) : file))
827
+
828
+ for (const file of inScope) {
829
+ if (scanned >= JS_MAX_FILES) {
830
+ skipped.budget.push({ file: labelOf(file), reason: 'fileCountLimit', bytes: null, detail: `超过文件数上限 ${JS_MAX_FILES}` })
831
+ continue
832
+ }
833
+ let bytes
834
+ if (mode === 'asar') {
835
+ const member = memberIndex.get(file)
836
+ if (member.size > JS_MAX_FILE_BYTES) {
837
+ skipped.tooLarge.push({ file: labelOf(file), reason: 'tooLarge', bytes: member.size })
838
+ continue
839
+ }
840
+ if (scannedBytes + member.size > JS_MAX_TOTAL_BYTES) {
841
+ skipped.budget.push({ file: labelOf(file), reason: 'totalBudget', bytes: member.size, detail: `已读 ${scannedBytes} 字节,再加它超过总预算 ${JS_MAX_TOTAL_BYTES}` })
842
+ continue
843
+ }
844
+ const read = await readRange(shell, exec, path, archive.contentBase + member.offset, member.size)
845
+ bytes = read
846
+ } else {
847
+ let read
848
+ try {
849
+ read = await readSmallFile(shell, exec, file, 'JS 文件', JS_MAX_FILE_BYTES)
850
+ } catch (error) {
851
+ skipped.unreadable.push({ file: labelOf(file), reason: 'unreadable', bytes: null, detail: `${error.name}: ${error.message}`.slice(0, 200) })
852
+ continue
853
+ }
854
+ if (read.tooLarge) {
855
+ skipped.tooLarge.push({ file: labelOf(file), reason: 'tooLarge', bytes: read.size })
856
+ continue
857
+ }
858
+ if (scannedBytes + read.bytes.length > JS_MAX_TOTAL_BYTES) {
859
+ skipped.budget.push({ file: labelOf(file), reason: 'totalBudget', bytes: read.size, detail: `已读 ${scannedBytes} 字节,再加它超过总预算 ${JS_MAX_TOTAL_BYTES}` })
860
+ continue
861
+ }
862
+ bytes = read.bytes
863
+ }
864
+ scanned += 1
865
+ scannedBytes += bytes.length
866
+ scanText(bytes.toString('utf8'), labelOf(file), hits.collect)
867
+ }
868
+
869
+ const collected = hits.result()
870
+ const endpoints = bound(collected.endpoints, top)
871
+ const channels = bound(collected.channels, top)
872
+ const filesWithHits = bound(collected.filesWithHits, top)
873
+ const skippedFiles = bound([...skipped.tooLarge, ...skipped.budget, ...skipped.unreadable], top)
874
+ const reasons = {
875
+ tooLarge: skipped.tooLarge.length,
876
+ budget: skipped.budget.length,
877
+ unreadable: skipped.unreadable.length,
878
+ nodeModules: skipped.nodeModules.length,
879
+ }
880
+ const skippedTotal = reasons.tooLarge + reasons.budget + reasons.unreadable + reasons.nodeModules
881
+ const coverage = {
882
+ mode,
883
+ scope,
884
+ candidates: list.length,
885
+ inScope: inScope.length,
886
+ scanned,
887
+ nonJsFiles: nonJs,
888
+ ...(mode === 'asar' ? { unpackedJsSkipped: unpackedJs } : {}),
889
+ bytes: { scanned: scannedBytes, candidate: candidateBytes },
890
+ skipped: { total: skippedTotal, reasons, files: skippedFiles.rows },
891
+ limits: { maxFileBytes: JS_MAX_FILE_BYTES, maxTotalBytes: JS_MAX_TOTAL_BYTES, maxFiles: JS_MAX_FILES },
892
+ filesWithHits: filesWithHits.rows,
893
+ summary: `扫了 ${scanned} 个 JS 文件(${scannedBytes} 字节),跳过 ${skippedTotal} 个(${
894
+ Object.entries(reasons).filter(([, count]) => count > 0).map(([reason, count]) => `${reason} ${count}`).join(' / ') || '无'
895
+ });候选 ${list.length} 个${nonJs > 0 ? `,另有 ${nonJs} 个非 JS 文件不在扫描面` : ''}`,
896
+ complete: skippedTotal === 0 && scanned === inScope.length,
897
+ }
898
+
899
+ const result = {
900
+ tool: 'artifact_js_boundaries',
901
+ target: path,
902
+ operation: 'scan a JS tree (or the JS members of an ASAR) for literal network endpoints and Electron IPC channels',
903
+ mode,
904
+ asar: archive === null ? null : archive.format,
905
+ coverage,
906
+ endpoints: endpoints.rows,
907
+ channels: channels.rows,
908
+ hits: { total: collected.sites, rows: collected.rows.length, endpoints: collected.endpoints.length, channels: collected.channels.length },
909
+ top,
910
+ bounds: {
911
+ endpoints: endpoints.bounds,
912
+ channels: channels.bounds,
913
+ skippedFiles: skippedFiles.bounds,
914
+ filesWithHits: filesWithHits.bounds,
915
+ },
916
+ confidence: 'observed',
917
+ }
918
+ result.truncated = Object.values(result.bounds).some(entryBounds => entryBounds.omitted > 0)
919
+ result.limitations = jsLimitations({
920
+ mode,
921
+ coverage,
922
+ scope,
923
+ top,
924
+ truncated: result.truncated,
925
+ hits: collected,
926
+ filesWithHits: collected.filesWithHits,
927
+ })
928
+ return JSON.stringify(result, null, 2)
929
+ },
930
+ }))
931
+ }