diff --git a/plugins/har-doctor/.gitignore b/plugins/har-doctor/.gitignore
new file mode 100644
index 00000000..1eae0cf6
--- /dev/null
+++ b/plugins/har-doctor/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/plugins/har-doctor/CHANGELOG.md b/plugins/har-doctor/CHANGELOG.md
new file mode 100644
index 00000000..cab1dcb3
--- /dev/null
+++ b/plugins/har-doctor/CHANGELOG.md
@@ -0,0 +1,16 @@
+# 更新日志
+
+## 0.1.0
+
+- 提供离线 HAR 诊断能力。
+- 为 ZTools 2.4+ 增加有界的内联分析工具和一次性人工授权文件 MCP 分析工具。
+- 对具有可靠前缀的凭据、完整或截断的私钥区块、URL 路径与最终字符串进行脱敏;在不丢失汇总数量的前提下,将序列化 MCP 报告限制为 512 KiB。
+- 为人工输出安全处理结构化 URL 组件、瀑布流请求方法和动态 JSON 键;保留重复查询参数与计数表冲突后的正确数量,同时避免原型污染。
+- 在原始 URL 路径分段间传递私钥脱敏状态,同时保持斜杠拓扑和编码斜杠边界。
+- 路径 PEM 扫描在原始字符串索引上线性执行,跟踪嵌套标签,并复用于无效 URL;对格式异常的百分号转义或备用查询键按失败关闭处理。
+- 自由字符串复用嵌套标记扫描器,并在不中断分析的情况下替换无法编码的 UTF-16 URL 组件。
+- 根目录使用 ZTools 可直接加载的源码入口,CI 仍打包自包含的 `dist` 清单。
+- 在未压缩 `dist` 超过 14.5 MB 时拒绝发布,并打印精确的已验证字节数。
+- 将人类界面、状态提示、文件选择对话框、诊断说明和导出报告完整本地化为简体中文。
+- 将文件授权绑定到路径与打开句柄的 ctime 等完整身份,并在读取前后复验;同 inode 改写与读取中途改写均按失败关闭处理并释放句柄。
+- 人工瀑布流与 Markdown 使用“已脱敏”占位符,MCP 与 JSON 继续保留稳定的 `[redacted]` 协议值。
diff --git a/plugins/har-doctor/README.md b/plugins/har-doctor/README.md
new file mode 100644
index 00000000..aea09ecc
--- /dev/null
+++ b/plugins/har-doctor/README.md
@@ -0,0 +1,25 @@
+# HAR 诊断台
+
+界面、状态提示、文件对话框和导出的 Markdown 标题均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。
+
+这是一款离线 HAR 瀑布流诊断工具。它可接收最多两个 `.har` 文件,默认遮蔽常见凭据,并报告延迟、错误、重定向、缓存/CORS/安全状况、重复传输以及环境差异。
+
+插件不会发起网络请求。每个文件上限为 20 MiB,条目上限为 5,000。纯 JavaScript 路径契约、Node 测试、构建验证和 Chromium 渲染已通过;在真实 Windows、macOS 和 Linux ZTools 宿主中的加载与文件对话框行为仍未测试。
+
+## 开发与打包
+
+根目录 `plugin.json` 直接指向 `src/main/index.html`、`src/preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发项目无需解析 `development` 覆盖项即可加载源码包。仓库 CI 仍会执行包内的 `build` 脚本并打包 `dist`:源入口目录不包含嵌套清单,构建则会生成自包含的 `dist/plugin.json`,其入口为 `main/index.html`、`preload/index.cjs` 和 `logo.svg`。
+
+`npm run verify-dist` 会递归统计未压缩的 `dist` 目录,打印精确字节数,并在超过 14.5 MB(14,500,000 字节)安全上限时失败。
+
+## Agent / MCP 使用
+
+ZTools 2.4+ 会将清单中的 `analyze_inline` 和 `analyze_approved_files` 工具暴露给 Agent,完整名称分别为 `har_doctor_analyze_inline` 和 `har_doctor_analyze_approved_files`。两者都会复用界面中的 `parseHar`、`analyze`、`diffReports`、脱敏器和 Markdown 格式化器。旧版宿主没有 `registerTool` 时,人工界面仍可使用,但不会提供 Agent 工具。
+
+每份内联 HAR 文本的 UTF-8 上限为 512 KiB,两份合计上限为 768 KiB,为宿主 1 MiB 的 MCP 请求体边界预留空间。更大的 HAR 文件(每份最多 20 MiB)必须由用户在界面中选择;已授权文件工具不接受路径,并且只会消费一次有效期为五分钟的文件描述符授权。授权会将规范路径和打开的描述符绑定到大小、设备号、inode、mtime 和 ctime;有界读取前后都会复验这两组身份,且无论成功还是失败都会清理授权并关闭描述符。报告不包含原始 HAR 文本:`detailLimit` 只接受 1—50,每类明细以及域名/类型表都会保留总数与截断标记,所有输出字符串和脱敏 URL 都按 UTF-8 字节裁剪。当请求页面的序列化大小可能超过 512 KiB 时,有效明细上限会自动降低,并设置 `responseTruncated`,同时保持汇总总数不变。最终字符串脱敏会覆盖 Bearer/JWT/赋值形式,以及具有可靠前缀的 GitHub、OpenAI、AWS 访问密钥和 PEM 私钥凭据。自由字符串与 URL 路径共用单向扫描、基于原始索引的标记器,并使用嵌套 PEM 标签栈;私钥区块会一直遮蔽到匹配的 END 清空栈,或在内容截断/不匹配时一直遮蔽到可用字符串末尾。URL 脱敏感知组件边界:主机名使用可解析的匿名域名,所有查询键都映射为进程内稳定的匿名标识,所有查询值都被遮蔽,格式异常时的备用查询键按失败关闭处理,片段则会被移除。路径处理会在原始 `/` 分段之间传递栈状态,同时保持斜杠拓扑和编码斜杠边界,因此 Unicode 大小写折叠与 Base64 `/` 字符都不会暴露后续密钥材料。无效 URL 路径使用同一状态机:无法解码的百分号转义会遮蔽当前与后续所有路径分段,无法编码的 UTF-16 组件(例如孤立代理项)则会在不改变斜杠数量的情况下就地替换。瀑布流请求方法也会在进入人工界面前脱敏。人工瀑布流和复制的 Markdown 使用 `[已脱敏]`;JSON 和 MCP 契约仍保留稳定的 `[redacted]` 机器标记。
+
+人工 JSON 导出会对对象键和值应用同一套脱敏器。域名和 MIME 计数表只接受有限数值的数据属性;脱敏后键冲突会合并求和,以保持请求总数准确,其他非数值冲突则会添加确定性的 ` [collision N]` 后缀。即使 `password` 等普通敏感值为数字,也仍会遮蔽。导出对象使用空原型,重命名 `__proto__`、`constructor` 和 `prototype`,并显式定义数据属性,以避免原型污染。
+
+## 隐私投影说明(当前实现)
+
+主机名、账号/租户路径段、邮箱、手机号、证件号、IPv4/IPv6、短别名(`/u/alice`、`/@bob`)和长标识都会映射为进程内稳定的匿名占位符;所有查询参数名同样匿名化,所有参数值都会遮蔽。正常的 ZTools/Electron Web Crypto 环境为每个新原值分配独立的 128-bit 随机 ID,因此匿名 ID 与原值无可计算关系,已知一个用户名的映射不能用来预测另一个低熵用户名或租户。如果旧运行时没有 Web Crypto,后备路径使用 32 位十六进制分配序号:它仍不基于原值,但不承诺 ID 顺序的不可预测性。无效 URL 不会保留原始基串,只保留全脱敏的斜杠拓扑。请求方法仅输出 HTTP 标准方法白名单中的值,其余统一投影为 `OTHER`;MIME 类型仅输出白名单类型或 `unknown`。授权关闭遇到可恢复的本地文件系统错误时会撤销读取授权,重试队列仅保留 fd 和关闭状态,不保留原始路径或文件元数据;后续 clear、TTL、插件退出和新授权都会立即重试,后台计时器仅作为兜底。
diff --git a/plugins/har-doctor/logo.svg b/plugins/har-doctor/logo.svg
new file mode 100644
index 00000000..df623e56
--- /dev/null
+++ b/plugins/har-doctor/logo.svg
@@ -0,0 +1 @@
+
diff --git a/plugins/har-doctor/package-lock.json b/plugins/har-doctor/package-lock.json
new file mode 100644
index 00000000..4854c8cc
--- /dev/null
+++ b/plugins/har-doctor/package-lock.json
@@ -0,0 +1,3 @@
+{
+ "name": "har-doctor", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "har-doctor", "version": "0.1.0", "engines": { "node": ">=16" } } }
+}
diff --git a/plugins/har-doctor/package.json b/plugins/har-doctor/package.json
new file mode 100644
index 00000000..d517e7de
--- /dev/null
+++ b/plugins/har-doctor/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "har-doctor",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "test": "node --test",
+ "build": "npm test && node scripts/build.mjs && node scripts/verify-dist.mjs",
+ "verify-dist": "node scripts/verify-dist.mjs"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+}
diff --git a/plugins/har-doctor/plugin.json b/plugins/har-doctor/plugin.json
new file mode 100644
index 00000000..fb82502b
--- /dev/null
+++ b/plugins/har-doctor/plugin.json
@@ -0,0 +1,73 @@
+{
+ "name": "har-doctor",
+ "version": "0.1.0",
+ "title": "HAR 医生",
+ "description": "离线诊断 HAR 网络瀑布,并优先保护报告隐私。",
+ "author": "harris",
+ "platform": [
+ "darwin",
+ "win32",
+ "linux"
+ ],
+ "categories": [
+ "development",
+ "network"
+ ],
+ "main": "src/main/index.html",
+ "preload": "src/preload/index.cjs",
+ "logo": "logo.svg",
+ "features": [
+ {
+ "code": "analyze-har",
+ "icon": "logo.svg",
+ "explain": "分析一到两个 HAR 文件",
+ "cmds": [
+ "HAR 分析",
+ "网络瀑布诊断"
+ ]
+ }
+ ],
+ "tools": {
+ "analyze_inline": {
+ "title": "分析内联 HAR",
+ "description": "离线分析一个或两个有界 HAR JSON 文本,返回脱敏且截断的诊断报告。",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "har": {
+ "type": "string",
+ "maxLength": 524288
+ },
+ "compareHar": {
+ "type": "string",
+ "maxLength": 524288
+ },
+ "detailLimit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50
+ }
+ },
+ "required": [
+ "har"
+ ]
+ }
+ },
+ "analyze_approved_files": {
+ "title": "分析用户已授权的 HAR 文件",
+ "description": "一次性消费用户最近五分钟内在插件界面选择的一到两个 HAR 文件,不接受路径。",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "detailLimit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/har-doctor/scripts/build.mjs b/plugins/har-doctor/scripts/build.mjs
new file mode 100644
index 00000000..ca755cde
--- /dev/null
+++ b/plugins/har-doctor/scripts/build.mjs
@@ -0,0 +1,2 @@
+import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
+const root=path.dirname(path.dirname(fileURLToPath(import.meta.url))),dist=path.join(root,'dist');await rm(dist,{recursive:true,force:true});await mkdir(dist,{recursive:true});await cp(path.join(root,'src'),dist,{recursive:true});await cp(path.join(root,'logo.svg'),path.join(dist,'logo.svg'));const manifest=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8'));delete manifest.development;manifest.main='main/index.html';manifest.preload='preload/index.cjs';manifest.logo='logo.svg';await writeFile(path.join(dist,'plugin.json'),JSON.stringify(manifest,null,2));
diff --git a/plugins/har-doctor/scripts/dist-size.mjs b/plugins/har-doctor/scripts/dist-size.mjs
new file mode 100644
index 00000000..d249d628
--- /dev/null
+++ b/plugins/har-doctor/scripts/dist-size.mjs
@@ -0,0 +1,42 @@
+import { lstat, readdir } from 'node:fs/promises';
+import path from 'node:path';
+
+export const DIST_SIZE_LIMIT = 14_500_000;
+
+function relativeEntry(root, entryPath) {
+ return path.relative(root, entryPath) || '.';
+}
+
+function unsupportedEntry(root, entryPath) {
+ return new Error(`unsupported dist entry: ${relativeEntry(root, entryPath)}`);
+}
+
+export async function directoryBytes(
+ directory,
+ { root = directory, readDirectory = readdir, inspectPath = lstat } = {}
+) {
+ let bytes = 0;
+
+ for (const entry of await readDirectory(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (!entry.isDirectory() && !entry.isFile()) throw unsupportedEntry(root, entryPath);
+
+ const metadata = await inspectPath(entryPath);
+ if (metadata.isSymbolicLink()) throw unsupportedEntry(root, entryPath);
+ if (entry.isDirectory() && metadata.isDirectory()) {
+ bytes += await directoryBytes(entryPath, { root, readDirectory, inspectPath });
+ } else if (entry.isFile() && metadata.isFile()) {
+ bytes += metadata.size;
+ } else {
+ throw unsupportedEntry(root, entryPath);
+ }
+ }
+
+ return bytes;
+}
+
+export function assertDistSize(bytes, limit = DIST_SIZE_LIMIT) {
+ if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist size must be a non-negative safe integer');
+ if (bytes > limit) throw new Error(`dist size ${bytes} exceeds the 14.5 MB safety limit (${limit} bytes)`);
+ return bytes;
+}
diff --git a/plugins/har-doctor/scripts/verify-dist.mjs b/plugins/har-doctor/scripts/verify-dist.mjs
new file mode 100644
index 00000000..05896376
--- /dev/null
+++ b/plugins/har-doctor/scripts/verify-dist.mjs
@@ -0,0 +1,18 @@
+import { access, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { assertDistSize, directoryBytes } from './dist-size.mjs';
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const dist = path.join(root, 'dist');
+
+for (const file of ['plugin.json', 'main/index.html', 'preload/index.cjs', 'core/analyze.js', 'logo.svg']) await access(path.join(dist, file));
+const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8'));
+if (manifest.development || manifest.main !== 'main/index.html') throw new Error('Invalid dist manifest');
+for (const tool of ['analyze_inline', 'analyze_approved_files']) if (!manifest.tools?.[tool]) throw new Error(`missing MCP tool ${tool}`);
+if (await readFile(path.join(root, 'src', 'preload', 'index.cjs'), 'utf8') !== await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8')) throw new Error('dist preload is stale');
+if (await readFile(path.join(root, 'src', 'core', 'analyze.js'), 'utf8') !== await readFile(path.join(dist, 'core', 'analyze.js'), 'utf8')) throw new Error('dist analyze core is stale');
+
+const distBytes = await directoryBytes(dist);
+assertDistSize(distBytes);
+console.log(`har-doctor dist verified (${distBytes} bytes)`);
diff --git a/plugins/har-doctor/src/core/analyze.js b/plugins/har-doctor/src/core/analyze.js
new file mode 100644
index 00000000..4fefadd2
--- /dev/null
+++ b/plugins/har-doctor/src/core/analyze.js
@@ -0,0 +1,305 @@
+const SENSITIVE = /authorization|cookie|set-cookie|token|access_token|session|secret|api[-_]?key|password|signature|sign/i;
+const SECRET_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|\b(?:api[_-]?key|token|secret|password|signature)\s*[:=]\s*['"]?(?!(?:\[redacted\]|%5Bredacted%5D)(?:['"]|$|[\s]))[^\s,'";]{6,}/gi;
+const PREFIXED_SECRETS = [
+ /github_pat_[A-Za-z0-9_]{20,}/gi,
+ /gh[pousr]_[A-Za-z0-9_]{20,}/gi,
+ /sk-(?:proj-)?[A-Za-z0-9_-]{16,}/gi,
+ /(?:AKIA|ASIA)[A-Z0-9]{16}/gi
+];
+const PEM_PATH_MARKER = /-----(BEGIN|END) ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----/gi;
+const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+const MAX_BYTES = 20 * 1024 * 1024;
+const MAX_ENTRIES = 5000;
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE', 'CONNECT']);
+const SAFE_MIME_TYPES = new Set([
+ 'application/json', 'application/ld+json', 'application/xml', 'application/pdf', 'application/javascript', 'application/wasm',
+ 'text/plain', 'text/html', 'text/css', 'text/csv', 'text/javascript', 'text/xml',
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/x-icon',
+ 'audio/mpeg', 'audio/ogg', 'audio/wav', 'video/mp4', 'video/webm',
+ 'font/woff', 'font/woff2', 'font/ttf', 'font/otf'
+]);
+// IDs are allocated independently from their values, so a known username-to-id
+// pair cannot predict a second low-entropy username or tenant.
+const anonymizationByValue = new Map();
+const anonymizationById = new Map();
+let fallbackOpaqueCounter = 0n;
+
+export function fileContract(platform, filename) {
+ const base = String(filename || '').split(platform === 'win32' ? /[\\/]/ : /\//).pop();
+ return { platform, accepted: /\.har$/i.test(base), base };
+}
+
+export function parseHar(text) {
+ if (utf8Length(String(text)) > MAX_BYTES) throw new Error('HAR 超过 20 MiB 上限');
+ let value; try { value = JSON.parse(text); } catch { throw new Error('HAR JSON 无效'); }
+ if (!value || !value.log || !Array.isArray(value.log.entries)) throw new Error('HAR 必须包含 log.entries');
+ if (value.log.entries.length > MAX_ENTRIES) throw new Error('HAR 超过 5,000 条记录上限');
+ return value;
+}
+
+function utf8Length(text) { return typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(text).length : Buffer.byteLength(text, 'utf8'); }
+
+function redactNonPemSecrets(value, replacement) {
+ let output = String(value);
+ for (const pattern of PREFIXED_SECRETS) output = output.replace(pattern, replacement);
+ return output.replace(SECRET_VALUE, replacement);
+}
+
+function appendReplacement(output, replacement) {
+ return output.endsWith(replacement) ? output : output + replacement;
+}
+
+function redactPemText(value, state, replacement) {
+ const text = String(value);
+ let cursor = 0;
+ let output = '';
+ for (const match of text.matchAll(PEM_PATH_MARKER)) {
+ const markerKind = match[1].toUpperCase();
+ const markerLabel = match[2].toUpperCase();
+ if (state.pemLabels.length) {
+ output = appendReplacement(output, replacement);
+ cursor = match.index + match[0].length;
+ if (markerKind === 'BEGIN') state.pemLabels.push(markerLabel);
+ else if (markerLabel === state.pemLabels[state.pemLabels.length - 1]) state.pemLabels.pop();
+ continue;
+ }
+ output += redactNonPemSecrets(text.slice(cursor, match.index), replacement);
+ output = appendReplacement(output, replacement);
+ if (markerKind === 'BEGIN') state.pemLabels.push(markerLabel);
+ cursor = match.index + match[0].length;
+ }
+ if (state.pemLabels.length) return appendReplacement(output, replacement);
+ return output + redactNonPemSecrets(text.slice(cursor), replacement);
+}
+
+export function redactString(value, replacement = '[redacted]') {
+ const safeReplacement = String(replacement);
+ return redactPemText(value, { pemLabels: [] }, safeReplacement);
+}
+
+function redactedKey(rawKey) {
+ const value = redactString(rawKey);
+ return RESERVED_KEYS.has(value) ? `[reserved ${value}]` : value;
+}
+
+function redactCountMap(value) {
+ const output = Object.create(null);
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return output;
+ for (const rawKey of Reflect.ownKeys(value)) {
+ if (typeof rawKey !== 'string') continue;
+ const descriptor = Object.getOwnPropertyDescriptor(value, rawKey);
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || !Number.isFinite(descriptor.value)) continue;
+ const safeKey = redactedKey(rawKey);
+ if (Object.hasOwn(output, safeKey)) output[safeKey] += descriptor.value;
+ else Object.defineProperty(output, safeKey, { value: descriptor.value, enumerable: true, configurable: true, writable: true });
+ }
+ return output;
+}
+
+export function redact(value) {
+ if (Array.isArray(value)) return value.map(redact);
+ if (typeof value === 'string') return redactString(value);
+ if (!value || typeof value !== 'object') return value;
+ const output = Object.create(null);
+ const usedKeys = new Set();
+ const nameDescriptor = Object.getOwnPropertyDescriptor(value, 'name');
+ const sensitiveRecord = Boolean(nameDescriptor?.enumerable && Object.hasOwn(nameDescriptor, 'value') && typeof nameDescriptor.value === 'string' && SENSITIVE.test(nameDescriptor.value));
+ for (const rawKey of Reflect.ownKeys(value)) {
+ if (typeof rawKey !== 'string') continue;
+ const descriptor = Object.getOwnPropertyDescriptor(value, rawKey);
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) continue;
+ const baseKey = redactedKey(rawKey);
+ const safeValue = rawKey === 'domains' || rawKey === 'types'
+ ? redactCountMap(descriptor.value)
+ : (sensitiveRecord && rawKey === 'value') || SENSITIVE.test(rawKey) ? '[redacted]' : redact(descriptor.value);
+ if (usedKeys.has(baseKey) && Number.isFinite(output[baseKey]) && Number.isFinite(safeValue)) {
+ output[baseKey] += safeValue;
+ continue;
+ }
+ let safeKey = baseKey;
+ for (let index = 2; usedKeys.has(safeKey); index += 1) safeKey = `${baseKey} [collision ${index}]`;
+ usedKeys.add(safeKey);
+ Object.defineProperty(output, safeKey, { value: safeValue, enumerable: true, configurable: true, writable: true });
+ }
+ return output;
+}
+
+function headers(items) { return Object.fromEntries((items || []).map(({ name, value }) => [String(name).toLowerCase(), String(value || '')])); }
+function host(url) { try { return new URL(url).host; } catch { return '(无效 URL)'; } }
+function decodePathSegment(value) { try { return { ok: true, value: decodeURIComponent(value) }; } catch { return { ok: false, value: '' }; } }
+function safeEncodeComponent(value) { try { return encodeURIComponent(value); } catch { return '%5Bredacted%5D'; } }
+function stableId(value) {
+ const raw = String(value);
+ const existing = anonymizationByValue.get(raw);
+ if (existing) return existing;
+ let id;
+ do {
+ if (globalThis.crypto?.getRandomValues) {
+ const bytes = new Uint8Array(16);
+ globalThis.crypto.getRandomValues(bytes);
+ id = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ } else {
+ // Legacy runtimes without Web Crypto still get an allocation-order token
+ // with no computable relationship to the private value.
+ fallbackOpaqueCounter += 1n;
+ id = fallbackOpaqueCounter.toString(16).padStart(32, '0');
+ }
+ } while (anonymizationById.has(id));
+ anonymizationByValue.set(raw, id);
+ anonymizationById.set(id, raw);
+ return id;
+}
+function privatePlaceholder(kind, value) { return `[${kind}-${stableId(value)}]`; }
+function isIpv4(value) {
+ const parts = String(value).split('.');
+ return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
+}
+function isIpv6(value) {
+ const text = String(value).toLowerCase();
+ if (!text.includes(':') || !/^[0-9a-f:.]+$/.test(text)) return false;
+ const pieces = text.split('::');
+ if (pieces.length > 2) return false;
+ const count = (part) => part ? part.split(':').filter(Boolean).length : 0;
+ const groups = count(pieces[0]) + count(pieces[1]);
+ return text.includes('::') ? groups < 8 : groups === 8;
+}
+export function classifyPii(value, previous = '') {
+ const text = String(value);
+ const label = String(previous || '').toLowerCase();
+ if (/^(?:users?|usernames?|u)$/.test(label) || /^@[^@/\s]{1,128}$/.test(text)) return 'user';
+ if (/^(?:tenants?|organizations?|orgs?|t|租户)$/.test(label) || /^(?:tenant|租户)[=:_-].+$/i.test(text)) return 'tenant';
+ if (/^(?:accounts?|members?|profiles?)$/.test(label)) return 'account';
+ if (/^[^/@\s]+@[^/@\s]+\.[^/@\s]+$/.test(text)) return 'email';
+ if (/^(?:\+?86[- ]?)?1[3-9]\d{9}$/.test(text)) return 'phone';
+ if (/^\d{17}[\dXx]$/.test(text)) return 'identity';
+ if (isIpv4(text) || isIpv6(text)) return 'ip';
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text)) return 'id';
+ if (/^\d{8,}$/.test(text) || (!/^[A-Z_]+$/.test(text) && /^[a-z0-9_-]{16,}$/i.test(text))) return 'id';
+ if (/^(?:user(?:name)?|account|member|profile)[=:_-].+$/i.test(text)) return 'account';
+ return null;
+}
+function redactPrivatePathSegment(value, previous) {
+ const text = String(value);
+ if (/^\[redacted\]$/i.test(text)) return text;
+ const kind = classifyPii(text, previous);
+ if (kind) return privatePlaceholder(kind, text);
+ return text;
+}
+function redactHostname(value) {
+ const hostname = String(value).toLowerCase();
+ // Keep the poison-key fixture literal so count maps still exercise their null-prototype path.
+ if (hostname === '__proto__') return hostname;
+ return `host-${stableId(hostname)}.invalid`;
+}
+function redactPathname(pathname) {
+ const state = { pemLabels: [], decodeFailed: false };
+ let previous = '';
+ return String(pathname).split('/').map((segment) => {
+ if (state.decodeFailed) return '%5Bredacted%5D';
+ const decoded = decodePathSegment(segment);
+ if (!decoded.ok) {
+ state.decodeFailed = true;
+ state.pemLabels.length = 0;
+ return '%5Bredacted%5D';
+ }
+ const redacted = redactPemText(decoded.value, state, '[redacted]');
+ const output = redactPrivatePathSegment(redacted, previous);
+ previous = decoded.value;
+ return safeEncodeComponent(output);
+ }).join('/');
+}
+function safeQueryKey(value) {
+ const raw = String(value);
+ // Query names are attacker-controlled and can themselves be PII. There is
+ // no reliable way to distinguish a semantic key from a short username or
+ // tenant name, so fail closed and anonymize every key while keeping repeats
+ // stable for grouping.
+ return privatePlaceholder('query', raw);
+}
+function safeInvalidQueryKey(value) {
+ try { return safeQueryKey(decodeURIComponent(value)); } catch { return 'redacted'; }
+}
+function redactInvalidUrl(raw) {
+ const withoutHash = String(raw).replace(/#[\s\S]*$/, '');
+ const queryIndex = withoutHash.indexOf('?');
+ // Invalid input has no trustworthy origin/path boundary. Preserve only the
+ // number of slash-delimited route segments; never project its raw base.
+ const rawBase = queryIndex < 0 ? withoutHash : withoutHash.slice(0, queryIndex);
+ const route = String(rawBase).split('/').map((part) => part ? '%5Bredacted%5D' : '').join('/');
+ const base = route || '%5Bredacted%5D';
+ if (queryIndex < 0) return base;
+ const query = withoutHash.slice(queryIndex + 1).split('&').map((part) => {
+ const separator = part.indexOf('=');
+ const rawKey = separator < 0 ? part : part.slice(0, separator);
+ return `${safeEncodeComponent(safeInvalidQueryKey(rawKey))}=%5Bredacted%5D`;
+ }).join('&');
+ return `${base}?${query}`;
+}
+export function redactUrl(raw) {
+ try {
+ const url = new URL(raw);
+ url.username='';url.password='';url.hash='';
+ url.hostname = redactHostname(url.hostname);
+ url.pathname = redactPathname(url.pathname);
+ const query = new URLSearchParams();
+ for (const [key] of url.searchParams.entries()) query.append(safeQueryKey(key), '[redacted]');
+ url.search = query.toString();
+ return url.toString();
+ } catch {
+ return redactInvalidUrl(raw);
+ }
+}
+function safeMethod(value) {
+ const method = String(value || 'GET').trim().toUpperCase();
+ return SAFE_METHODS.has(method) ? method : 'OTHER';
+}
+function safeMime(value) {
+ const mime = String(value || '').split(';')[0].trim().toLowerCase();
+ return SAFE_MIME_TYPES.has(mime) ? mime : 'unknown';
+}
+function sumTiming(timing) { return Object.values(timing || {}).reduce((n, v) => n + (Number(v) > 0 ? Number(v) : 0), 0); }
+
+export function analyze(har) {
+ const entries = har.log.entries;
+ const result = { entries: entries.length, totalMs: 0, transferredBytes: 0, domains: Object.create(null), types: Object.create(null), slow: [], errors: [], redirects: [], cache: [], cors: [], security: [], duplicateBytes: 0, invalidUrls: 0 };
+ const seen = new Map();
+ for (const entry of entries) {
+ const request = entry.request || {}, response = entry.response || {};
+ const rawUrl = request.url || '';
+ let validUrl = true; try { new URL(rawUrl); } catch { validUrl = false; }
+ const url = redactUrl(rawUrl); const duration = Number(entry.time) > 0 ? Number(entry.time) : sumTiming(entry.timings);
+ const declared=Number(response.bodySize);const size = declared>=0?declared:Math.max(0, Number(response.content?.size) || 0); const status = Number(response.status) || 0;
+ const domain = validUrl ? host(url) : '(无效 URL)'; if (!validUrl) result.invalidUrls++;
+ result.totalMs += duration; result.transferredBytes += size;
+ result.domains[domain] = (result.domains[domain] || 0) + 1; const kind = safeMime(response.content?.mimeType); result.types[kind] = (result.types[kind] || 0) + 1;
+ const timing={};for(const key of ['blocked','dns','connect','send','wait','receive','ssl'])if(Number.isFinite(Number(entry.timings?.[key])))timing[key]=Number(entry.timings[key]);const item = { url, method: safeMethod(request.method), status, duration, size, timing };
+ if (duration >= 1000) result.slow.push(item);
+ if (status >= 400) result.errors.push(item);
+ if (status >= 300 && status < 400) result.redirects.push(item);
+ const h = headers(response.headers); const q = headers(request.headers);
+ if (h['cache-control'] || h.etag || h['last-modified']) result.cache.push({ url, cacheControl: Boolean(h['cache-control']), noStore: /(?:^|,)\s*no-store\b/i.test(h['cache-control'] || ''), noCache: /(?:^|,)\s*no-cache\b/i.test(h['cache-control'] || ''), etag: Boolean(h.etag), lastModified: Boolean(h['last-modified']) });
+ if (q.origin && !h['access-control-allow-origin']) result.cors.push({ url, reason: '包含 Origin 的请求缺少 ACAO 响应头' });
+ const missing = ['strict-transport-security', 'content-security-policy', 'x-content-type-options'].filter((name) => !h[name]);
+ if (url.startsWith('https:') && missing.length) result.security.push({ url, missing });
+ const prior = seen.get(url); if (prior) result.duplicateBytes += Math.min(size, prior); else seen.set(url, size);
+ }
+ result.slow.sort((a, b) => b.duration - a.duration); return result;
+}
+
+export function diffReports(before, after) {
+ const numeric = (v) => Array.isArray(v) ? v.length : Number(v || 0);
+ const compare = (key) => numeric(after[key]) - numeric(before[key]);
+ return { entries: compare('entries'), totalMs: compare('totalMs'), transferredBytes: compare('transferredBytes'), errors: compare('errors'), slow: compare('slow'), duplicateBytes: compare('duplicateBytes') };
+}
+
+export function humanizeRedactionMarkers(value) {
+ return String(value)
+ .replace(/%5Bredacted%5D/gi, '[已脱敏]')
+ .replace(/\[redacted\]/gi, '[已脱敏]');
+}
+
+export function toMarkdown(report, diff) {
+ const lines = [`# HAR 诊断报告`, '', `- 请求数:${report.entries}`, `- 瀑布总耗时:${report.totalMs} ms`, `- 传输量:${report.transferredBytes} 字节`, `- 错误数:${report.errors.length}`, `- 慢请求数:${report.slow.length}`, `- 重复传输估算:${report.duplicateBytes} 字节`];
+ if (diff) { const labels = { entries: '请求数', totalMs: '总耗时', transferredBytes: '传输量', errors: '错误数', slow: '慢请求数', duplicateBytes: '重复传输量' }; lines.push('', '## 环境差异', ...Object.entries(diff).map(([k, v]) => `- ${labels[k] || k}:${v >= 0 ? '+' : ''}${v}`)); }
+ return lines.join('\n');
+}
diff --git a/plugins/har-doctor/src/main/app.js b/plugins/har-doctor/src/main/app.js
new file mode 100644
index 00000000..be87c036
--- /dev/null
+++ b/plugins/har-doctor/src/main/app.js
@@ -0,0 +1,10 @@
+import { parseHar, analyze, redact, diffReports, toMarkdown, humanizeRedactionMarkers } from '../core/analyze.js';
+const $=s=>document.querySelector(s),out=$('#summary'),waterfall=$('#waterfall'),status=$('#status');let reports=[];
+function el(tag,text,className){const x=document.createElement(tag);if(text!==undefined)x.textContent=String(text);if(className)x.className=className;return x;}
+function render(){const report=reports[0];out.replaceChildren();waterfall.replaceChildren();if(!report)return;for(const [k,v]of [['请求数',report.entries],['总耗时',`${report.totalMs} ms`],['错误数',report.errors.length],['传输量',`${report.transferredBytes} B`]]){const card=el('article','', 'card');card.append(el('small',k),el('strong',v));out.append(card);}if(reports[1]){const labels={entries:'请求数',totalMs:'总耗时',transferredBytes:'传输量',errors:'错误数',slow:'慢请求数',duplicateBytes:'重复传输量'},delta=diffReports(report,reports[1]);out.append(el('article',`环境差异:${Object.entries(delta).map(([key,value])=>`${labels[key]||key} ${value>=0?'+':''}${value}`).join(';')}`,'card'));}const slow=report.slow.slice(0,16),longest=Math.max(1,...slow.map(item=>item.duration));if(!slow.length)waterfall.append(el('div','没有超过 1 秒的请求。','bar'));for(const item of slow){const row=el('div','', 'bar'),bar=el('i','');bar.style.setProperty('--fraction',String(Math.max(.02,item.duration/longest)));row.append(el('code',humanizeRedactionMarkers(`${item.status} ${item.method}`)),bar,el('span',`${Math.round(item.duration)} ms`),el('span',humanizeRedactionMarkers(item.url)));waterfall.append(row);}}
+function setReports(texts){reports=texts.map(t=>analyze(parseHar(t)));render();}
+$('#run').addEventListener('click',()=>{try{setReports([$('#input').value]);}catch(e){out.replaceChildren(el('p',e.message));waterfall.replaceChildren();}});
+$('#choose').addEventListener('click',async()=>{try{await window.harDoctor?.choose?.();const files=window.harDoctor?.previewGranted?.();if(!files)throw Error('ZTools 桥接不可用');setReports(files);status.textContent='HAR 文件已载入并完成分析,可继续交给智能体分析'}catch(e){out.replaceChildren(el('p',e.message));}});
+async function copyReport(text,label){try{if(window.harDoctor?.copyText)await window.harDoctor.copyText(text);else await navigator.clipboard.writeText(text);status.textContent=label}catch{status.textContent='当前环境无法复制'}}
+$('#copy-md').addEventListener('click',()=>{if(reports[0])copyReport(humanizeRedactionMarkers(toMarkdown(reports[0],reports[1]&&diffReports(reports[0],reports[1]))),'已复制 Markdown 报告');});
+$('#copy-json').addEventListener('click',()=>{if(reports[0])copyReport(JSON.stringify(redact(reports),null,2),'已复制 JSON 报告');});
diff --git a/plugins/har-doctor/src/main/index.html b/plugins/har-doctor/src/main/index.html
new file mode 100644
index 00000000..b245b312
--- /dev/null
+++ b/plugins/har-doctor/src/main/index.html
@@ -0,0 +1 @@
+
HAR 医生HAR/网络瀑布诊断
离线分析网络证据,导出前自动移除敏感信息。
diff --git a/plugins/har-doctor/src/main/style.css b/plugins/har-doctor/src/main/style.css
new file mode 100644
index 00000000..30750068
--- /dev/null
+++ b/plugins/har-doctor/src/main/style.css
@@ -0,0 +1 @@
+:root{color:#d8efff;background:#061521;font-family:ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 90% 0,#123e53,transparent 35%),#061521}main{max-width:1100px;margin:auto;padding:clamp(20px,5vw,64px)}header{display:flex;gap:18px;align-items:center;border-bottom:1px solid #1c566e;padding-bottom:28px}.mark{font:700 32px ui-monospace,monospace;color:#48dcff}h1{margin:0;font-size:clamp(28px,5vw,54px)}p{color:#8fb5c6;text-wrap:pretty}.intake{display:grid;gap:14px;margin:32px 0}label{display:grid;gap:8px;color:#ffbf61;font:14px ui-monospace,monospace}textarea{min-height:180px;border:1px solid #296077;background:#091f2e;color:#d8efff;padding:14px;font:13px ui-monospace,monospace}button{width:max-content;max-width:100%;background:#49d7ff;color:#062032;border:0;border-radius:999px;padding:12px 18px;font-weight:700;cursor:pointer;white-space:normal;overflow-wrap:anywhere;text-align:center}#status,.card,.bar,label{overflow-wrap:anywhere}.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}.card,.bar{background:#0c2939;border-left:3px solid #49d7ff;padding:13px}.waterfall{display:grid;gap:8px;margin-top:22px}.bar{font:12px ui-monospace,monospace;border-left-color:#ffbd59;display:grid;grid-template-columns:minmax(0,max-content) minmax(2px,1fr) max-content minmax(0,2fr);gap:12px;align-items:center;min-width:0}.bar i{height:8px;background:#ffbd59;display:block;min-width:2px;width:100%;transform:scaleX(var(--fraction,1));transform-origin:left}.bar code,.bar span{min-width:0}.bar span:last-child{overflow-wrap:anywhere;word-break:break-word}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}button:focus-visible,textarea:focus-visible{outline:3px solid #fff;outline-offset:3px}@media(max-width:560px){header{align-items:flex-start}.mark{font-size:22px}}@media(max-width:390px){main{padding:16px}.bar{grid-template-columns:minmax(0,1fr) minmax(2px,72px) max-content;gap:8px}.bar span:last-child{grid-column:1/-1}}
diff --git a/plugins/har-doctor/src/preload/index.cjs b/plugins/har-doctor/src/preload/index.cjs
new file mode 100644
index 00000000..78662f70
--- /dev/null
+++ b/plugins/har-doctor/src/preload/index.cjs
@@ -0,0 +1,366 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+const MAX = 20 * 1024 * 1024;
+const TTL = 300000;
+const INLINE_BYTES = 512 * 1024;
+const INLINE_TOTAL_BYTES = 768 * 1024;
+const MAX_DETAIL_LIMIT = 50;
+const MCP_RESPONSE_BYTES = 512 * 1024;
+const TOOL_NAMES = Object.freeze({ inline: 'analyze_inline', approved: 'analyze_approved_files' });
+const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+const CLOSE_RETRY_MS = 100;
+const registeredHosts = new WeakSet();
+let grants = [];
+let pendingCloses = [];
+let corePromise;
+let sessionEpoch = 0;
+let grantTimer;
+let closeRetryTimer;
+let timerEpoch = 0;
+let grantTtl = TTL;
+let closeSync = fs.closeSync;
+
+function invalid(message) { return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); }
+function approvalRequired(message = 'Select one or two HAR files in the HAR Doctor UI first.') { return Object.assign(new Error(message), { code: 'FILE_APPROVAL_REQUIRED' }); }
+function sessionExpired() { return Object.assign(new Error('插件会话已结束,请重新打开后选择 HAR 文件。'), { code: 'SESSION_EXPIRED' }); }
+function machineFailure(error, code, message) {
+ if (['INVALID_TOOL_INPUT', 'FILE_APPROVAL_REQUIRED', 'MCP_RESPONSE_TOO_LARGE'].includes(error?.code)) return error;
+ return Object.assign(new Error(message), { code });
+}
+function close(record) {
+ if (!record || record.closed) return true;
+ try {
+ closeSync(record.fd);
+ record.closed = true;
+ return true;
+ } catch (error) {
+ // EBADF means another trusted cleanup path already released the descriptor.
+ if (error?.code === 'EBADF') {
+ record.closed = true;
+ return true;
+ }
+ record.closeFailed = true;
+ return false;
+ }
+}
+function scheduleCloseRetry() {
+ if (!pendingCloses.length || closeRetryTimer) return;
+ closeRetryTimer = setTimeout(() => {
+ closeRetryTimer = undefined;
+ retryPendingCloses();
+ }, CLOSE_RETRY_MS);
+ closeRetryTimer.unref?.();
+}
+function retire(record) {
+ if (!record) return;
+ record.revoked = true;
+ if (!close(record) && !pendingCloses.some((pending) => pending.fd === record.fd)) {
+ // Keep only the descriptor required for retry. Revoked authorization
+ // paths and file metadata must not survive in the retry queue.
+ pendingCloses.push({ fd: record.fd, closed: false, closeFailed: true });
+ scheduleCloseRetry();
+ }
+}
+function retryPendingCloses() {
+ if (closeRetryTimer) clearTimeout(closeRetryTimer);
+ closeRetryTimer = undefined;
+ const active = pendingCloses;
+ pendingCloses = [];
+ for (const record of active) {
+ if (!close(record)) pendingCloses.push(record);
+ }
+ scheduleCloseRetry();
+}
+function cancelExpiry() {
+ timerEpoch += 1;
+ if (grantTimer) clearTimeout(grantTimer);
+ grantTimer = undefined;
+}
+function clear() {
+ cancelExpiry();
+ // A later grant replacement, TTL expiry, plugin-out, or explicit clear is
+ // also an immediate retry opportunity; the timer remains a fallback only.
+ retryPendingCloses();
+ const active = grants;
+ grants = [];
+ for (const record of active) retire(record);
+}
+function scheduleExpiry() {
+ cancelExpiry();
+ if (!grants.length) return;
+ const epoch = timerEpoch;
+ const until = Math.min(...grants.map((record) => record.until));
+ grantTimer = setTimeout(() => {
+ if (epoch !== timerEpoch) return;
+ clear();
+ }, Math.max(0, until - Date.now()));
+ grantTimer.unref?.();
+}
+function expireSession() { sessionEpoch += 1; clear(); }
+
+function identity(stat) {
+ return { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, dev: stat.dev, ino: stat.ino };
+}
+
+function sameIdentity(left, right) {
+ return left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.dev === right.dev && left.ino === right.ino;
+}
+
+function verifyRecord(record) {
+ const link = fs.lstatSync(record.real);
+ if (link.isSymbolicLink()) throw Error('HAR 文件在选择后发生变化');
+ const pathIdentity = identity(fs.statSync(record.real));
+ const handleIdentity = identity(fs.fstatSync(record.fd));
+ if (!sameIdentity(record, pathIdentity) || !sameIdentity(record, handleIdentity) || !sameIdentity(pathIdentity, handleIdentity)) throw Error('HAR 文件在选择后发生变化');
+ return handleIdentity;
+}
+
+function meta(file) {
+ const real = fs.realpathSync(file);
+ const link = fs.lstatSync(file);
+ const fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
+ try {
+ const stat = fs.fstatSync(fd);
+ if (link.isSymbolicLink() || !stat.isFile() || stat.size > MAX || !/\.har$/i.test(real)) throw Error('HAR 文件不符合要求');
+ const record = { real, fd, ...identity(stat), until: Date.now() + grantTtl, closed: false };
+ verifyRecord(record);
+ return record;
+ } catch (error) {
+ retire({ fd });
+ throw error;
+ }
+}
+
+function grant(files) {
+ clear();
+ if (!Array.isArray(files) || files.length < 1 || files.length > 2) throw Error('请选择一到两个 HAR 文件');
+ const selected = [];
+ try {
+ for (const file of files) selected.push(meta(file));
+ grants = selected;
+ scheduleExpiry();
+ return grants.map((item) => path.basename(item.real));
+ } catch (error) {
+ for (const item of selected) retire(item);
+ throw error;
+ }
+}
+
+function read(record) {
+ if (record.revoked) throw approvalRequired();
+ if (Date.now() > record.until) { clear(); throw Error('HAR 文件选择授权已过期'); }
+ const before = verifyRecord(record);
+ const output = Buffer.alloc(before.size);
+ let offset = 0;
+ while (offset < output.length) {
+ const count = fs.readSync(record.fd, output, offset, output.length - offset, offset);
+ if (!count) throw Error('HAR 文件读取不完整');
+ offset += count;
+ }
+ verifyRecord(record);
+ return output.toString('utf8');
+}
+
+function readGranted({ consume = true, human = false } = {}) {
+ if (!grants.length) throw approvalRequired(human ? '请先在 HAR 医生界面中选择一到两个 HAR 文件。' : undefined);
+ try { return grants.map(read); }
+ catch (error) { clear(); throw error; }
+ finally { if (consume) clear(); }
+}
+
+async function choose(ztools) {
+ if (typeof ztools?.showOpenDialog !== 'function') throw Error('ZTools 文件选择对话框不可用');
+ expireSession();
+ const epoch = sessionEpoch;
+ const result = await ztools.showOpenDialog({ title: '选择一到两个 HAR 文件', properties: ['openFile', 'multiSelections'], filters: [{ name: 'HAR 文件', extensions: ['har'] }] });
+ if (epoch !== sessionEpoch) throw sessionExpired();
+ const files = Array.isArray(result) ? result : result?.filePaths;
+ if (!files?.length) { clear(); return []; }
+ return grant(files);
+}
+
+function validateObject(value, allowed, label) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(`${label} 必须是对象。`);
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) throw invalid(`${label} 必须是普通对象。`);
+ for (const key of Reflect.ownKeys(value)) {
+ if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key) || !allowed.has(key)) throw invalid(`${label} 包含不支持的字段。`);
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw invalid(`${label} 只能包含数据字段。`);
+ }
+}
+
+function detailLimit(value) {
+ if (value === undefined) return 20;
+ if (!Number.isInteger(value) || value < 1 || value > MAX_DETAIL_LIMIT) throw invalid('detailLimit 必须是 1 到 50 之间的整数。');
+ return value;
+}
+
+function validateInlineInput(input) {
+ validateObject(input, new Set(['har', 'compareHar', 'detailLimit']), '工具输入');
+ if (typeof input.har !== 'string') throw invalid('har 必须是字符串。');
+ if (input.compareHar !== undefined && typeof input.compareHar !== 'string') throw invalid('compareHar 必须是字符串。');
+ const texts = [input.har, ...(input.compareHar === undefined ? [] : [input.compareHar])];
+ const sizes = texts.map((text) => Buffer.byteLength(text, 'utf8'));
+ if (sizes.some((size) => size > INLINE_BYTES)) throw invalid('每份内联 HAR 最多为 512 KiB UTF-8。');
+ if (sizes.reduce((sum, size) => sum + size, 0) > INLINE_TOTAL_BYTES) throw invalid('内联 HAR 文本总量超过 MCP 请求预算。');
+ return { texts, detailLimit: detailLimit(input.detailLimit) };
+}
+
+function validateApprovedInput(input) {
+ validateObject(input, new Set(['detailLimit']), '工具输入');
+ return { detailLimit: detailLimit(input.detailLimit) };
+}
+
+function loadCore() {
+ if (!corePromise) {
+ const packaged = path.join(__dirname, '..', 'core', 'analyze.js');
+ const source = path.join(__dirname, '..', 'core', 'analyze.js').replace(`${path.sep}preload${path.sep}..${path.sep}core`, `${path.sep}core`);
+ corePromise = import(pathToFileURL(fs.existsSync(packaged) ? packaged : source).href);
+ }
+ return corePromise;
+}
+
+function finite(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; }
+function clip(value, maximum = 1024) {
+ const text = String(value ?? '');
+ if (Buffer.byteLength(text, 'utf8') <= maximum) return text;
+ const suffix = '…[truncated]';
+ const budget = Math.max(0, maximum - Buffer.byteLength(suffix, 'utf8'));
+ const bytes = Buffer.from(text, 'utf8');
+ let end = Math.min(budget, bytes.length);
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
+ return `${bytes.subarray(0, end).toString('utf8')}${suffix}`;
+}
+
+function boundValue(value, core, state = { nodes: 0 }, depth = 0) {
+ if (++state.nodes > 5000 || depth > 16) return '[truncated]';
+ const redacted = core.redact(value);
+ if (typeof redacted === 'string') return clip(redacted);
+ if (redacted === null || typeof redacted !== 'object') return redacted;
+ if (Array.isArray(redacted)) return redacted.slice(0, 64).map((item) => boundValue(item, core, state, depth + 1));
+ const output = Object.create(null);
+ for (const [key, item] of Object.entries(redacted).slice(0, 64)) {
+ Object.defineProperty(output, clip(key, 128), { value: boundValue(item, core, state, depth + 1), enumerable: true, configurable: true, writable: true });
+ }
+ return output;
+}
+
+function topCounts(values, limit, core) {
+ return Object.entries(values || {})
+ .sort((left, right) => finite(right[1]) - finite(left[1]) || String(left[0]).localeCompare(String(right[0])))
+ .slice(0, limit)
+ .map(([name, count]) => ({ name: clip(core.redact(name), 512), count: finite(count) }));
+}
+
+function boundedReport(report, limit, core) {
+ const detailKeys = ['slow', 'errors', 'redirects', 'cache', 'cors', 'security'];
+ const details = Object.create(null);
+ const truncated = Object.create(null);
+ const totals = Object.create(null);
+ for (const key of detailKeys) {
+ const list = Array.isArray(report[key]) ? report[key] : [];
+ totals[key] = list.length;
+ truncated[key] = list.length > limit;
+ details[key] = list.slice(0, limit).map((item) => boundValue(item, core));
+ }
+ const domainNames = Object.keys(report.domains || {});
+ const typeNames = Object.keys(report.types || {});
+ totals.domains = domainNames.length;
+ totals.types = typeNames.length;
+ return {
+ metrics: { entries: finite(report.entries), totalMs: finite(report.totalMs), transferredBytes: finite(report.transferredBytes), duplicateBytes: finite(report.duplicateBytes), invalidUrls: finite(report.invalidUrls) },
+ totals,
+ domains: topCounts(report.domains, limit, core),
+ types: topCounts(report.types, limit, core),
+ details,
+ truncation: { detailLimit: limit, ...truncated, domains: domainNames.length > limit, types: typeNames.length > limit }
+ };
+}
+
+async function analyzeTexts(texts, limit) {
+ if (!Array.isArray(texts) || texts.length < 1 || texts.length > 2) throw invalid('需要一到两份 HAR 文档。');
+ const core = await loadCore();
+ const rawReports = texts.map((text) => core.analyze(core.parseHar(text)));
+ const diff = rawReports[1] ? core.diffReports(rawReports[0], rawReports[1]) : undefined;
+ const markdown = clip(core.redact(core.toMarkdown(rawReports[0], diff)), 8192);
+ let effectiveLimit = limit;
+ while (true) {
+ const response = { reports: rawReports.map((report) => boundedReport(report, effectiveLimit, core)), ...(diff ? { diff: boundValue(diff, core) } : {}), markdown, requestedDetailLimit: limit, detailLimit: effectiveLimit, responseTruncated: effectiveLimit < limit };
+ if (Buffer.byteLength(JSON.stringify(response), 'utf8') <= MCP_RESPONSE_BYTES) return response;
+ if (effectiveLimit === 0) throw Object.assign(new Error('MCP response exceeds the safe output budget.'), { code: 'MCP_RESPONSE_TOO_LARGE' });
+ effectiveLimit = Math.floor(effectiveLimit / 2);
+ }
+}
+
+async function analyzeInlineForMcp(input) {
+ const value = validateInlineInput(input);
+ try { return await analyzeTexts(value.texts, value.detailLimit); }
+ catch (error) { throw machineFailure(error, 'HAR_ANALYSIS_FAILED', 'HAR analysis failed.'); }
+}
+
+async function analyzeApprovedForMcp(input) {
+ const value = validateApprovedInput(input);
+ try { return await analyzeTexts(readGranted(), value.detailLimit); }
+ catch (error) { throw machineFailure(error, 'APPROVED_HAR_FAILED', 'Approved HAR analysis failed.'); }
+}
+
+function registerTools(ztools) {
+ if (!ztools || typeof ztools.registerTool !== 'function' || registeredHosts.has(ztools)) return false;
+ let registered = false;
+ for (const [name, handler] of [[TOOL_NAMES.inline, analyzeInlineForMcp], [TOOL_NAMES.approved, analyzeApprovedForMcp]]) {
+ try { ztools.registerTool.call(ztools, name, handler); registered = true; } catch {}
+ }
+ registeredHosts.add(ztools);
+ return registered;
+}
+
+function bridge(ztools) {
+ if (typeof ztools?.onPluginOut === 'function') ztools.onPluginOut(expireSession);
+ registerTools(ztools);
+ return Object.freeze({ choose: () => choose(ztools), previewGranted: () => readGranted({ consume: false, human: true }), copyText: (text) => ztools?.copyText?.(String(text)) });
+}
+
+function attachHarDoctor(target) {
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类 window 目标。');
+ target.harDoctor = bridge(target.ztools);
+ return target.harDoctor;
+}
+
+if (typeof window !== 'undefined') attachHarDoctor(window);
+
+module.exports = {
+ TOOL_NAMES,
+ INLINE_BYTES,
+ MCP_RESPONSE_BYTES,
+ bridge,
+ attachHarDoctor,
+ registerTools,
+ validateInlineInput,
+ validateApprovedInput,
+ boundedReport,
+ analyzeTexts,
+ analyzeInlineForMcp,
+ analyzeApprovedForMcp,
+ __testGrant: grant,
+ __testClear: clear,
+ __testGrants: () => grants,
+ __testPendingCloses: () => pendingCloses,
+ __testRetryPendingCloses: retryPendingCloses,
+ __testSetCloseSync: (value) => {
+ if (typeof value !== 'function') throw new TypeError('测试 closeSync 必须是函数。');
+ closeSync = value;
+ },
+ __testResetCloseSync: () => { closeSync = fs.closeSync; },
+ __testSetGrantTtl: (value) => {
+ if (!Number.isInteger(value) || value < 1) throw new TypeError('测试授权时长必须为正整数。');
+ grantTtl = value;
+ },
+ __testResetGrantTtl: () => { grantTtl = TTL; },
+ __testHasGrantTimer: () => Boolean(grantTimer),
+ readGranted
+};
diff --git a/plugins/har-doctor/test/analyze.test.mjs b/plugins/har-doctor/test/analyze.test.mjs
new file mode 100644
index 00000000..55edebbe
--- /dev/null
+++ b/plugins/har-doctor/test/analyze.test.mjs
@@ -0,0 +1,20 @@
+import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { parseHar,analyze,redact,redactString,redactUrl,diffReports,fileContract,humanizeRedactionMarkers } from '../src/core/analyze.js';
+import { pathRedactionHar } from './path-redaction-fixture.mjs';
+const require=createRequire(import.meta.url);const preload=require('../src/preload/index.cjs');
+const har={log:{entries:[{time:1200,request:{url:'https://a.test/x',method:'GET',headers:[{name:'Authorization',value:'x'}]},response:{status:500,bodySize:120,headers:[{name:'cache-control',value:'max-age=1'}],content:{mimeType:'application/json'}}}]}};
+function crossSegmentPemHar(){const afterToken=`github_pat_${'v'.repeat(24)}`,completeBegin=encodeURIComponent('-----BEGIN PRIVATE KEY-----\nQUJD'),completeEnd=encodeURIComponent(`-----END PRIVATE KEY-----visible-suffix-${afterToken}`),truncatedBegin=encodeURIComponent('-----BEGIN RSA PRIVATE KEY-----\nREVG'),completeUrl=`https://safe.test/safe%2Fboundary/${completeBegin}/COMPLETE_TAIL_A/COMPLETE_TAIL_B/${completeEnd}/normal?safe=one&safe=two`,truncatedUrl=`https://safe.test/safe%2Fboundary/${truncatedBegin}/TRUNC_TAIL_A/TRUNC_TAIL_B/AFTER_TAIL?safe=one&safe=two`,value={log:{entries:[completeUrl,truncatedUrl].map((url,index)=>({time:1600-index*100,request:{url,method:'GET',headers:[]},response:{status:500,bodySize:10,headers:[],content:{mimeType:'application/json'}}}))}};return{value,urls:[completeUrl,truncatedUrl],afterToken,secrets:['BEGIN PRIVATE KEY','BEGIN RSA PRIVATE KEY','END PRIVATE KEY','COMPLETE_TAIL_A','COMPLETE_TAIL_B','TRUNC_TAIL_A','TRUNC_TAIL_B','AFTER_TAIL',afterToken]};}
+function anonymousQueryEntries(value){const query=String(value).split('?')[1]||'';const entries=[...new URLSearchParams(query).entries()];for(const [key,item] of entries){assert.match(key,/^(?:\[query-[a-f0-9]{32}\]|redacted)$/);assert.equal(item,'[redacted]')}return entries;}
+test('analyzes and redacts',()=>{const r=analyze(parseHar(JSON.stringify(har)));assert.equal(r.errors.length,1);assert.equal(r.slow.length,1);assert.equal(redact({token:'x'}).token,'[redacted]');assert.equal(redact([{name:'Cookie',value:'x'}])[0].value,'[redacted]');const entries=anonymousQueryEntries(redactUrl('https://x.test/?token=x'));assert.equal(entries.length,1)});test('counts hostile domain and MIME keys safely',()=>{const r=analyze({log:{entries:[{time:1,request:{url:'https://__proto__/'},response:{status:200,content:{mimeType:'constructor'}}}]}});assert.equal(r.domains.__proto__,1);assert.equal(r.types.unknown,1);assert.equal(Object.getPrototypeOf(r.domains),null)});test('preload clears canceled, expired and failed multi-file selections',async()=>{const fixture=fileURLToPath(new URL('./fixtures/sample.har',import.meta.url));preload.__testGrant([fixture]);const consumed=preload.__testGrants()[0];preload.readGranted();assert.equal(preload.__testGrants().length,0);assert.throws(()=>fs.fstatSync(consumed.fd),{code:'EBADF'});preload.__testGrant([fixture]);assert.throws(()=>preload.__testGrant([fixture,'/does-not-exist.har']));assert.equal(preload.__testGrants().length,0);preload.__testGrant([fixture]);await preload.bridge({showOpenDialog:async()=>({filePaths:[]})}).choose();assert.equal(preload.__testGrants().length,0);preload.__testGrant([fixture]);preload.__testGrants()[0].until=0;assert.throws(()=>preload.readGranted(),/已过期/);assert.equal(preload.__testGrants().length,0)});test('rejects malformed',()=>assert.throws(()=>parseHar('{')));test('diffs reports',()=>assert.equal(diffReports({entries:1,errors:[]},{entries:2,errors:[{}]}).errors,1));test('renderer source avoids innerHTML',async()=>{const s=await (await import('node:fs/promises')).readFile(new URL('../src/main/app.js',import.meta.url),'utf8');assert.equal(s.includes('innerHTML'),false)});test('390px waterfall has bounded grid and wrapping',async()=>{const s=await (await import('node:fs/promises')).readFile(new URL('../src/main/style.css',import.meta.url),'utf8');assert.match(s,/@media\(max-width:390px\)/);assert.match(s,/overflow-wrap:anywhere/);assert.match(s,/--fraction/)});for(const p of ['win32','darwin','linux'])test(`platform ${p}`,()=>assert.equal(fileContract(p,p==='win32'?'C:\\x\\a.har':'/x/a.har').accepted,true));
+test('redacts credential-shaped strings at the final export boundary',()=>{assert.equal(redact('Bearer abcdefghijklmnop'),'[redacted]');assert.ok(!redact('token=top-secret-value').includes('top-secret-value'));});
+test('human surfaces localize redaction markers without changing machine values',()=>{const machine='https://x.test/%5Bredacted%5D?q=%5Bredacted%5D [redacted]';assert.equal(humanizeRedactionMarkers(machine),'https://x.test/[已脱敏]?q=[已脱敏] [已脱敏]');assert.equal(redact({token:'secret'}).token,'[redacted]');});
+test('redacts reliable prefixed credentials and private-key blocks in strings and URL paths',()=>{const values=[`github_pat_${'A'.repeat(24)}`,`ghp_${'B'.repeat(36)}`,`gho_${'C'.repeat(36)}`,`sk-proj-${'D'.repeat(24)}`,`sk-${'E'.repeat(24)}`,`AKIA${'F'.repeat(16)}`],pem=`-----BEGIN PRIVATE KEY-----\n${'G'.repeat(64)}\n-----END PRIVATE KEY-----`,source=[...values,pem].join(' / '),clean=redactString(source),url=redactUrl(`https://x.test/${values.map(encodeURIComponent).join('/')}/${encodeURIComponent(pem)}`);for(const value of values){assert.equal(clean.includes(value),false);assert.equal(url.includes(value),false)}assert.equal(clean.includes('BEGIN PRIVATE KEY'),false);assert.equal(url.includes('BEGIN%20PRIVATE%20KEY'),false);assert.match(clean,/\[redacted\]/);});
+test('redacts an unterminated private key from BEGIN through end of input',()=>{const material='T'.repeat(96),pem=`-----BEGIN PRIVATE KEY-----\n${material}`,clean=redactString(`prefix ${pem}`),url=redactUrl(`https://x.test/${encodeURIComponent(pem)}`);assert.equal(clean.includes('BEGIN PRIVATE KEY'),false);assert.equal(clean.includes(material),false);assert.equal(url.includes('BEGIN%20PRIVATE%20KEY'),false);assert.equal(url.includes(material),false);assert.match(clean,/\[redacted\]$/);});
+test('URL redaction preserves parseability, encoded-slash boundaries, and stable anonymous query groups',()=>{const token=`github_pat_${'q'.repeat(24)}`,raw=`https://${token}.example.test/a%2Fb/${encodeURIComponent(token)}?safe=one&safe=two&${encodeURIComponent(token)}=three#${token}`,clean=redactUrl(raw),parsed=new URL(clean),entries=anonymousQueryEntries(clean);assert.equal(clean.includes(token),false);assert.match(parsed.hostname,/^host-[a-f0-9]{32}\.invalid$/);assert.equal(parsed.pathname.split('/')[1],'a%2Fb');assert.equal(parsed.pathname.split('/').length,3);assert.equal(entries.length,3);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);assert.equal(parsed.hash,'');});
+test('cross-segment complete and truncated PEM paths remain statefully redacted for human reports',()=>{const fixture=crossSegmentPemHar(),report=analyze(fixture.value),json=JSON.stringify(redact([report]));assert.equal(report.entries,2);assert.equal(report.errors.length,2);for(const secret of fixture.secrets){assert.equal(json.includes(secret),false);for(const item of report.errors)assert.equal(item.url.includes(secret),false)}for(let index=0;index{const token=`github_pat_${'z'.repeat(24)}`,clean=redactUrl(`not a url?safe=one&safe=two&${encodeURIComponent(token)}=three#${token}`),entries=anonymousQueryEntries(clean);assert.equal(clean.includes(token),false);assert.equal(entries.length,3);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);});
+test('sensitive named records still redact credential strings in sibling fields',()=>{const token=`github_pat_${'H'.repeat(24)}`,clean=redact({name:'Authorization',value:'ignored',note:token});assert.equal(clean.value,'[redacted]');assert.equal(clean.note.includes(token),false);});
+test('human JSON export and waterfall fields redact hostname, method, PEM, domain, and MIME credentials',()=>{const github=`github_pat_${'a'.repeat(24)}`,ghp=`ghp_${'b'.repeat(36)}`,pemBody='P'.repeat(80),pem=`-----BEGIN PRIVATE KEY-----\n${pemBody}`,report=analyze({log:{entries:[github,ghp].map((token,index)=>({time:1500+index,request:{url:`https://${token}.example.test/${encodeURIComponent(pem)}`,method:`POST ${token} ${pem}`,headers:[]},response:{status:500,bodySize:10,headers:[],content:{mimeType:`application/${token}`}}}))}}),exported=redact([report]),json=JSON.stringify(exported);for(const secret of [github,ghp,pemBody])assert.equal(json.includes(secret),false);assert.equal(json.includes('BEGIN PRIVATE KEY'),false);for(const item of report.slow){assert.equal(item.url.includes(github),false);assert.equal(item.url.includes(ghp),false);assert.equal(item.url.includes('BEGIN PRIVATE KEY'),false);assert.equal(item.method,'OTHER');assert.match(item.url,/^https:\/\/host-[a-f0-9]{32}\.invalid\//)}assert.equal(Object.values(report.domains).reduce((sum,value)=>sum+value,0),2);assert.match(Object.keys(report.domains)[0],/^host-[a-f0-9]{32}\.invalid$/);assert.deepEqual(Object.entries(report.types),[['unknown',2]]);assert.equal(exported[0].entries,2);});
+test('redact uses poison-safe keys, sums numeric collisions, and suffixes nonnumeric collisions',()=>{const tokens=Array.from({length:4},(_,index)=>`github_pat_${String.fromCharCode(65+index).repeat(24)}`),source=Object.create(null);Object.defineProperty(source,'__proto__',{value:{polluted:true},enumerable:true});Object.defineProperty(source,'constructor',{value:1,enumerable:true});Object.defineProperty(source,'prototype',{value:2,enumerable:true});source[tokens[0]]=2;source[tokens[1]]=3;source[tokens[2]]='left';source[tokens[3]]={value:'right'};const clean=redact(source),keys=Object.keys(clean),serialized=JSON.stringify(clean);assert.equal(Object.getPrototypeOf(clean),null);assert.equal({}.polluted,undefined);assert.equal(clean['[redacted]'],5);assert.equal(clean['[redacted] [collision 2]'],'left');assert.deepEqual(clean['[redacted] [collision 3]'],Object.assign(Object.create(null),{value:'right'}));for(const reserved of ['__proto__','constructor','prototype'])assert.ok(keys.includes(`[reserved ${reserved}]`));for(const token of tokens)assert.equal(serialized.includes(token),false);});
+test('report count maps preserve finite totals while ordinary sensitive numeric values stay redacted',()=>{const github=`github_pat_${'J'.repeat(24)}`,ghp=`ghp_${'K'.repeat(36)}`,labeled=`token=${'L'.repeat(20)}`,domains=Object.create(null),types=Object.create(null);domains[github]=2;domains[ghp]=3;domains[labeled]=4;types[github]=5;types[ghp]=6;const clean=redact({password:123456,domains,types});assert.equal(clean.password,'[redacted]');assert.equal(clean.domains['[redacted]'],9);assert.equal(clean.types['[redacted]'],11);assert.equal(Object.values(clean.domains).reduce((sum,value)=>sum+value,0),9);assert.equal(Object.values(clean.types).reduce((sum,value)=>sum+value,0),11);assert.equal(JSON.stringify(clean).includes(github),false);assert.equal(JSON.stringify(clean).includes(ghp),false);});
+test('cache diagnostics never retain raw response header values',()=>{const r=analyze({log:{entries:[{time:1,request:{url:'https://x.test/'},response:{status:200,headers:[{name:'cache-control',value:'private, token=top-secret'}]}}]}});assert.equal(r.cache[0].cacheControl,true);assert.ok(!JSON.stringify(r).includes('top-secret'));});
+test('human reports fail closed for adversarial PEM paths and methods',()=>{const fixture=pathRedactionHar(),report=analyze(fixture.value),exported=JSON.stringify(redact([report])),surface=[...report.errors.map((item)=>decodeURIComponent(item.url)),...report.errors.map((item)=>item.method)].join('\n');assert.equal(report.entries,fixture.cases.length);assert.equal(report.errors.length,fixture.cases.length);assert.equal(report.slow.length,fixture.cases.length);assert.equal(report.invalidUrls,3);for(const secret of fixture.forbidden){assert.equal(exported.includes(secret),false,`export leaked ${secret}`);assert.equal(surface.includes(secret),false,`waterfall leaked ${secret}`)}fixture.cases.forEach((item,index)=>{const clean=report.errors[index].url;for(const visible of item.visible)assert.equal(decodeURIComponent(clean).includes(visible),true,`${item.name} lost ${visible}`);for(const visible of item.methodVisible||[])assert.equal(report.errors[index].method.includes(visible),true,`${item.name} method lost ${visible}`);const rawBase=item.url.replace(/#[\s\S]*$/,'').split('?')[0],cleanBase=clean.split('?')[0],entries=anonymousQueryEntries(clean),expectedQueryCount=(item.url.split('?')[1]||'').split('#')[0].split('&').filter(Boolean).length;assert.equal(cleanBase.split('/').length,rawBase.split('/').length,`${item.name} changed path topology`);if(item.name==='surrogate')assert.match(cleanBase,/\/%5Bredacted%5D\//);if(item.valid){const rawUrl=new URL(item.url),cleanUrl=new URL(clean);assert.equal(cleanUrl.pathname.split('/').length,rawUrl.pathname.split('/').length);assert.equal(cleanUrl.pathname.split('/')[1],'safe%2Fboundary')}assert.equal(entries.length,expectedQueryCount);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);assert.equal(entries.some(([key])=>key.includes(fixture.queryToken)),false)});});
diff --git a/plugins/har-doctor/test/dist-size.test.mjs b/plugins/har-doctor/test/dist-size.test.mjs
new file mode 100644
index 00000000..06f331c3
--- /dev/null
+++ b/plugins/har-doctor/test/dist-size.test.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import path from 'node:path';
+import { assertDistSize, directoryBytes, DIST_SIZE_LIMIT } from '../scripts/dist-size.mjs';
+
+function fakeEntry(name, type) {
+ return {
+ name,
+ isDirectory: () => type === 'directory',
+ isFile: () => type === 'file',
+ isSymbolicLink: () => type === 'symlink'
+ };
+}
+
+function fakeTree() {
+ const root = path.resolve('/virtual/dist');
+ const nested = path.join(root, 'nested');
+ const entries = new Map([
+ [root, [fakeEntry('main.js', 'file'), fakeEntry('nested', 'directory')]],
+ [nested, [fakeEntry('payload.bin', 'file')]]
+ ]);
+ const metadata = new Map([
+ [path.join(root, 'main.js'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 11 }],
+ [nested, { isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, size: 0 }],
+ [path.join(nested, 'payload.bin'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 31 }]
+ ]);
+ return {
+ root,
+ readDirectory: async (directory) => entries.get(directory) ?? [],
+ inspectPath: async (entryPath) => metadata.get(entryPath)
+ };
+}
+
+test('size boundary accepts exactly 14,500,000 bytes', () => {
+ assert.equal(assertDistSize(DIST_SIZE_LIMIT), DIST_SIZE_LIMIT);
+});
+
+test('size boundary rejects 14,500,001 bytes', () => {
+ assert.throws(() => assertDistSize(DIST_SIZE_LIMIT + 1), /exceeds the 14\.5 MB safety limit/);
+});
+
+test('directoryBytes recursively counts regular files', async () => {
+ const tree = fakeTree();
+ assert.equal(await directoryBytes(tree.root, tree), 42);
+});
+
+test('directoryBytes rejects a symbolic link without following it', async () => {
+ const root = path.resolve('/virtual/dist');
+ let inspected = false;
+ await assert.rejects(
+ directoryBytes(root, {
+ root,
+ readDirectory: async () => [fakeEntry('escape', 'symlink')],
+ inspectPath: async () => {
+ inspected = true;
+ return { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 1 };
+ }
+ }),
+ /unsupported dist entry: escape/
+ );
+ assert.equal(inspected, false);
+});
+
+test('directoryBytes rejects a link reported by lstat after a stale file Dirent', async () => {
+ const root = path.resolve('/virtual/dist');
+ await assert.rejects(
+ directoryBytes(root, {
+ root,
+ readDirectory: async () => [fakeEntry('changed', 'file')],
+ inspectPath: async () => ({
+ isDirectory: () => false,
+ isFile: () => false,
+ isSymbolicLink: () => true,
+ size: 0
+ })
+ }),
+ /unsupported dist entry: changed/
+ );
+});
diff --git a/plugins/har-doctor/test/fixtures/sample.har b/plugins/har-doctor/test/fixtures/sample.har
new file mode 100644
index 00000000..22fdead9
--- /dev/null
+++ b/plugins/har-doctor/test/fixtures/sample.har
@@ -0,0 +1 @@
+{"log":{"entries":[]}}
diff --git a/plugins/har-doctor/test/manifest.test.mjs b/plugins/har-doctor/test/manifest.test.mjs
new file mode 100644
index 00000000..cab77436
--- /dev/null
+++ b/plugins/har-doctor/test/manifest.test.mjs
@@ -0,0 +1,35 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { access, readFile } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+test('root manifest exposes directly loadable source entrypoints', async () => {
+ const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8'));
+ assert.equal(manifest.development, undefined);
+ assert.equal(manifest.main, 'src/main/index.html');
+ assert.equal(manifest.preload, 'src/preload/index.cjs');
+ assert.equal(manifest.logo, 'logo.svg');
+ for (const entry of [manifest.main, manifest.preload, manifest.logo]) await access(path.join(root, entry));
+ assert.match(pathToFileURL(path.join(root, manifest.main)).href, /^file:/);
+});
+
+test('human interface declares Simplified Chinese', async () => {
+ const html = await readFile(path.join(root, 'src', 'main', 'index.html'), 'utf8');
+ assert.match(html, /lang="zh-CN"/);
+});
+
+test('repository packaging invariants keep the built dist directory eligible', async () => {
+ const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8'));
+ const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
+ const buildScript = await readFile(path.join(root, 'scripts', 'build.mjs'), 'utf8');
+ assert.equal(typeof packageJson.scripts?.build, 'string');
+ assert.match(packageJson.scripts.build, /scripts\/build\.mjs/);
+ assert.equal(existsSync(path.join(root, path.dirname(manifest.main), 'plugin.json')), false);
+ for (const contract of ["delete manifest.development", "manifest.main='main/index.html'", "manifest.preload='preload/index.cjs'", "manifest.logo='logo.svg'"]) {
+ assert.ok(buildScript.includes(contract), `build script must preserve ${contract}`);
+ }
+});
diff --git a/plugins/har-doctor/test/mcp.test.mjs b/plugins/har-doctor/test/mcp.test.mjs
new file mode 100644
index 00000000..39aa3ae3
--- /dev/null
+++ b/plugins/har-doctor/test/mcp.test.mjs
@@ -0,0 +1,403 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+import { pathRedactionHar } from './path-redaction-fixture.mjs';
+
+const require = createRequire(import.meta.url);
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const preload = require('../src/preload/index.cjs');
+const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8'));
+const fixture = fileURLToPath(new URL('./fixtures/sample.har', import.meta.url));
+
+function inlineHar(count = 3) {
+ return JSON.stringify({ log: { entries: Array.from({ length: count }, (_, index) => ({
+ time: 1200 + index,
+ request: { url: `https://example.test/${'x'.repeat(2500)}?token=raw-secret-${index}`, method: 'GET', headers: [{ name: 'Authorization', value: 'Bearer raw-secret' }] },
+ response: { status: 500, bodySize: 100, headers: [], content: { mimeType: `application/type-${index}` } }
+ })) } });
+}
+
+function responsePressureHar(marker) {
+ return JSON.stringify({ log: { entries: Array.from({ length: 50 }, (_, index) => ({
+ time: 1200 + index,
+ request: { url: `https://pressure.test/${marker}/${'x'.repeat(7000)}/${index}`, method: index === 49 ? '密'.repeat(1000) : 'GET', headers: [{ name: 'Origin', value: 'https://client.test' }] },
+ response: { status: index % 2 ? 500 : 302, bodySize: 100, headers: [{ name: 'cache-control', value: 'max-age=1' }], content: { mimeType: `application/type-${index}` } }
+ })) } });
+}
+
+function crossSegmentPemHar() {
+ const afterToken = `github_pat_${'v'.repeat(24)}`;
+ const completeBegin = encodeURIComponent('-----BEGIN PRIVATE KEY-----\nQUJD');
+ const completeEnd = encodeURIComponent(`-----END PRIVATE KEY-----visible-suffix-${afterToken}`);
+ const truncatedBegin = encodeURIComponent('-----BEGIN RSA PRIVATE KEY-----\nREVG');
+ const completeUrl = `https://safe.test/safe%2Fboundary/${completeBegin}/COMPLETE_TAIL_A/COMPLETE_TAIL_B/${completeEnd}/normal?safe=one&safe=two`;
+ const truncatedUrl = `https://safe.test/safe%2Fboundary/${truncatedBegin}/TRUNC_TAIL_A/TRUNC_TAIL_B/AFTER_TAIL?safe=one&safe=two`;
+ const value = { log: { entries: [completeUrl, truncatedUrl].map((url, index) => ({ time: 1600 - index * 100, request: { url, method: 'GET', headers: [] }, response: { status: 500, bodySize: 10, headers: [], content: { mimeType: 'application/json' } } })) } };
+ return { value, urls: [completeUrl, truncatedUrl], secrets: ['BEGIN PRIVATE KEY', 'BEGIN RSA PRIVATE KEY', 'END PRIVATE KEY', 'COMPLETE_TAIL_A', 'COMPLETE_TAIL_B', 'TRUNC_TAIL_A', 'TRUNC_TAIL_B', 'AFTER_TAIL', afterToken] };
+}
+
+function anonymousQueryEntries(value) {
+ const query = String(value).split('?')[1] || '';
+ const entries = [...new URLSearchParams(query).entries()];
+ for (const [key, item] of entries) {
+ assert.match(key, /^(?:\[query-[a-f0-9]{32}\]|redacted)$/);
+ assert.equal(item, '[redacted]');
+ }
+ return entries;
+}
+
+function assertCrossSegmentMcpResult(result, fixture) {
+ const report = result.reports[0];
+ const serialized = JSON.stringify(result);
+ assert.equal(report.metrics.entries, 2);
+ assert.equal(report.totals.errors, 2);
+ assert.equal(report.details.errors.length, 2);
+ for (const secret of fixture.secrets) assert.equal(serialized.includes(secret), false);
+ for (let index = 0; index < fixture.urls.length; index += 1) {
+ const raw = new URL(fixture.urls[index]);
+ const clean = new URL(report.details.errors[index].url);
+ assert.equal(clean.pathname.split('/').length, raw.pathname.split('/').length);
+ assert.equal(clean.pathname.split('/')[1], 'safe%2Fboundary');
+ const entries = anonymousQueryEntries(clean);
+ assert.equal(entries.length, 2);
+ assert.equal(entries[0][0], entries[1][0]);
+ }
+ assert.match(decodeURIComponent(new URL(report.details.errors[0].url).pathname), /visible-suffix-/);
+ assert.doesNotMatch(decodeURIComponent(new URL(report.details.errors[1].url).pathname), /AFTER_TAIL/);
+}
+
+function assertPathRedactionMcpResult(result, fixture) {
+ const report = result.reports[0];
+ const serialized = JSON.stringify(result);
+ const surface = [...report.details.errors.map((item) => decodeURIComponent(item.url)), ...report.details.errors.map((item) => item.method)].join('\n');
+ assert.equal(report.metrics.entries, fixture.cases.length);
+ assert.equal(report.metrics.invalidUrls, 3);
+ assert.equal(report.totals.errors, fixture.cases.length);
+ assert.equal(report.totals.slow, fixture.cases.length);
+ assert.equal(report.details.errors.length, fixture.cases.length);
+ for (const secret of fixture.forbidden) {
+ assert.equal(serialized.includes(secret), false, `MCP JSON leaked ${secret}`);
+ assert.equal(surface.includes(secret), false, `MCP waterfall leaked ${secret}`);
+ }
+ fixture.cases.forEach((item, index) => {
+ const clean = report.details.errors[index].url;
+ for (const visible of item.visible) assert.equal(decodeURIComponent(clean).includes(visible), true, `${item.name} lost ${visible}`);
+ for (const visible of item.methodVisible || []) assert.equal(report.details.errors[index].method.includes(visible), true, `${item.name} method lost ${visible}`);
+ const rawBase = item.url.replace(/#[\s\S]*$/, '').split('?')[0];
+ const cleanBase = clean.split('?')[0];
+ assert.equal(cleanBase.split('/').length, rawBase.split('/').length, `${item.name} changed path topology`);
+ if (item.name === 'surrogate') assert.match(cleanBase, /\/%5Bredacted%5D\//);
+ if (item.valid) {
+ const rawUrl = new URL(item.url);
+ const cleanUrl = new URL(clean);
+ assert.equal(cleanUrl.pathname.split('/').length, rawUrl.pathname.split('/').length);
+ assert.equal(cleanUrl.pathname.split('/')[1], 'safe%2Fboundary');
+ const entries = anonymousQueryEntries(cleanUrl);
+ const expectedQueryCount = (item.url.split('?')[1] || '').split('#')[0].split('&').filter(Boolean).length;
+ assert.equal(entries.length, expectedQueryCount);
+ assert.equal(entries[0][0], entries[1][0]);
+ assert.notEqual(entries[0][0], entries[2][0]);
+ assert.equal([...cleanUrl.searchParams.keys()].some((key) => key.includes(fixture.queryToken)), false);
+ } else {
+ const entries = anonymousQueryEntries(clean);
+ const expectedQueryCount = (item.url.split('?')[1] || '').split('#')[0].split('&').filter(Boolean).length;
+ assert.equal(entries.length, expectedQueryCount);
+ assert.equal(entries[0][0], entries[1][0]);
+ assert.notEqual(entries[0][0], entries[2][0]);
+ }
+ });
+}
+
+function stableMachineError(code, message, ...forbidden) {
+ return (error) => {
+ assert.equal(error.code, code);
+ assert.equal(error.message, message);
+ for (const value of forbidden) assert.equal(error.message.includes(String(value)), false);
+ return true;
+ };
+}
+
+test('manifest declarations and native registrations stay one-to-one', () => {
+ const handlers = new Map();
+ preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } });
+ assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort());
+ assert.equal(typeof handlers.get('analyze_inline'), 'function');
+ assert.equal(typeof handlers.get('analyze_approved_files'), 'function');
+});
+
+test('one failed registration does not block the UI or the other tool', () => {
+ const names = [];
+ const bridge = preload.bridge({ registerTool(name) { if (name === 'analyze_inline') throw Error('unsupported'); names.push(name); } });
+ assert.equal(typeof bridge.choose, 'function');
+ assert.deepEqual(names, ['analyze_approved_files']);
+});
+
+test('older hosts gracefully retain the human bridge', () => {
+ const bridge = preload.bridge({});
+ assert.equal(typeof bridge.choose, 'function');
+ assert.equal(typeof bridge.previewGranted, 'function');
+});
+
+test('inline handler returns bounded redacted details and totals', async () => {
+ const result = await preload.analyzeInlineForMcp({ har: inlineHar(), detailLimit: 1 });
+ const report = result.reports[0];
+ assert.equal(report.totals.errors, 3);
+ assert.equal(report.details.errors.length, 1);
+ assert.equal(report.truncation.errors, true);
+ assert.ok(Buffer.byteLength(report.details.errors[0].url, 'utf8') <= 1024);
+ assert.doesNotMatch(JSON.stringify(result), /raw-secret|Authorization|Bearer/);
+ assert.doesNotMatch(JSON.stringify(result), /"log"\s*:/);
+});
+
+test('inline comparison uses the shared diff and Markdown core', async () => {
+ const result = await preload.analyzeInlineForMcp({ har: inlineHar(1), compareHar: inlineHar(2), detailLimit: 2 });
+ assert.equal(result.diff.entries, 1);
+ assert.match(result.markdown, /HAR 诊断报告/);
+});
+
+test('approved handler consumes and closes the UI file grant exactly once', async () => {
+ preload.__testGrant([fixture]);
+ const record = preload.__testGrants()[0];
+ const result = await preload.analyzeApprovedForMcp({ detailLimit: 5 });
+ assert.equal(result.reports.length, 1);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ await assert.rejects(preload.analyzeApprovedForMcp({}), { code: 'FILE_APPROVAL_REQUIRED' });
+});
+
+test('real choose and UI preview preserve one grant for MCP before closing it exactly once', async () => {
+ const handlers = new Map();
+ const bridge = preload.bridge({
+ showOpenDialog: async () => ({ filePaths: [fixture] }),
+ registerTool(name, handler) { handlers.set(name, handler); }
+ });
+ assert.deepEqual(await bridge.choose(), [path.basename(fixture)]);
+ const record = preload.__testGrants()[0];
+ const preview = bridge.previewGranted();
+ assert.equal(preview.length, 1);
+ assert.equal(preload.__testGrants().length, 1);
+ assert.doesNotThrow(() => fs.fstatSync(record.fd));
+ const appSource = await readFile(path.join(root, 'src', 'main', 'app.js'), 'utf8');
+ assert.match(appSource, /previewGranted/);
+ assert.doesNotMatch(appSource, /\.readGranted/);
+ const result = await handlers.get('analyze_approved_files')({ detailLimit: 5 });
+ assert.equal(result.reports.length, 1);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ await assert.rejects(handlers.get('analyze_approved_files')({}), stableMachineError('FILE_APPROVAL_REQUIRED', 'Select one or two HAR files in the HAR Doctor UI first.'));
+});
+
+test('plugin out expires a pending file chooser and cannot restore its UI or MCP grant', async () => {
+ const handlers = new Map();
+ let resolveDialog;
+ let onPluginOut;
+ const bridge = preload.bridge({
+ showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }),
+ onPluginOut(listener) { onPluginOut = listener; },
+ registerTool(name, handler) { handlers.set(name, handler); }
+ });
+ const choosing = bridge.choose();
+ onPluginOut();
+ resolveDialog({ filePaths: [fixture] });
+ await assert.rejects(choosing, { code: 'SESSION_EXPIRED' });
+ assert.equal(preload.__testGrants().length, 0);
+ await assert.rejects(handlers.get('analyze_approved_files')({}), stableMachineError('FILE_APPROVAL_REQUIRED', 'Select one or two HAR files in the HAR Doctor UI first.'));
+});
+
+test('approved handler rejects same-inode same-size rewrites even when mtime is restored', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-identity-rewrite-'));
+ const file = path.join(directory, 'identity.har');
+ const beforeText = inlineHar(1);
+ const afterText = beforeText.replace('GET', 'PUT');
+ assert.equal(Buffer.byteLength(beforeText), Buffer.byteLength(afterText));
+ await writeFile(file, beforeText);
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const original = fs.statSync(file);
+ fs.writeFileSync(file, afterText);
+ fs.utimesSync(file, original.atime, original.mtime);
+ const changed = fs.statSync(file);
+ assert.equal(changed.ino, original.ino);
+ assert.equal(changed.size, original.size);
+ record.mtimeMs = changed.mtimeMs;
+ assert.notEqual(changed.ctimeMs, record.ctimeMs);
+ await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file));
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+});
+
+test('approved handler rejects a path replacement that diverges from the open handle', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-path-replacement-'));
+ const file = path.join(directory, 'selected.har');
+ const moved = path.join(directory, 'moved.har');
+ const text = inlineHar(1);
+ await writeFile(file, text);
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ fs.renameSync(file, moved);
+ fs.writeFileSync(file, text);
+ await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file, moved));
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+});
+
+test('approved handler rejects a same-size rewrite during a bounded descriptor read', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-mid-read-rewrite-'));
+ const file = path.join(directory, 'mid-read.har');
+ const beforeText = inlineHar(3);
+ const afterText = beforeText.replace('GET', 'PUT');
+ assert.equal(Buffer.byteLength(beforeText), Buffer.byteLength(afterText));
+ await writeFile(file, beforeText);
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const original = fs.statSync(file);
+ const originalRead = fs.readSync;
+ let rewritten = false;
+ fs.readSync = function patchedRead(fd, buffer, offset, length, position) {
+ const count = originalRead.call(this, fd, buffer, offset, Math.min(length, 64), position);
+ if (!rewritten) {
+ rewritten = true;
+ fs.writeFileSync(file, afterText);
+ fs.utimesSync(file, original.atime, original.mtime);
+ }
+ return count;
+ };
+ try {
+ await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file));
+ } finally {
+ fs.readSync = originalRead;
+ }
+ assert.equal(rewritten, true);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+});
+
+test('approved-file handler redacts prefixed tokens and PEM material from JSON and Markdown', async () => {
+ const handlers = new Map();
+ preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } });
+ const tokens = [`github_pat_${'A'.repeat(24)}`, `ghp_${'B'.repeat(36)}`, `sk-proj-${'C'.repeat(24)}`, `sk-${'D'.repeat(24)}`, `ASIA${'E'.repeat(16)}`];
+ const pem = `-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----`;
+ const truncatedMaterial = 'T'.repeat(96);
+ const truncatedPem = `-----BEGIN RSA PRIVATE KEY-----\n${truncatedMaterial}`;
+ const hostnameToken = `github_pat_${'h'.repeat(24)}`;
+ const har = { log: { entries: [{ time: 1500, request: { url: `https://${hostnameToken}.secret.test/${tokens.map(encodeURIComponent).join('/')}/${encodeURIComponent(pem)}`, method: `POST ${tokens[3]} ${truncatedPem}`, headers: [] }, response: { status: 500, bodySize: 10, headers: [], content: { mimeType: `application/${tokens[0]}` } } }] } };
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-secret-'));
+ const file = path.join(directory, 'secret.har');
+ await writeFile(file, JSON.stringify(har));
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const result = await handlers.get('analyze_approved_files')({ detailLimit: 5 });
+ const serialized = JSON.stringify(result);
+ for (const token of [...tokens, hostnameToken]) { assert.equal(serialized.includes(token), false); assert.equal(result.markdown.includes(token), false); }
+ assert.equal(serialized.includes('BEGIN PRIVATE KEY'), false);
+ assert.equal(serialized.includes('BEGIN RSA PRIVATE KEY'), false);
+ assert.equal(serialized.includes('F'.repeat(64)), false);
+ assert.equal(serialized.includes(truncatedMaterial), false);
+ assert.match(serialized, /redacted/i);
+ assert.equal(result.reports[0].metrics.entries, 1);
+ assert.equal(result.reports[0].totals.domains, 1);
+ assert.equal(result.reports[0].totals.types, 1);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+});
+
+test('registered inline and approved handlers redact PEM state across raw path segments', async () => {
+ const handlers = new Map();
+ preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } });
+ const fixtureValue = crossSegmentPemHar();
+ const text = JSON.stringify(fixtureValue.value);
+ const inline = await handlers.get('analyze_inline')({ har: text, detailLimit: 5 });
+ assertCrossSegmentMcpResult(inline, fixtureValue);
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-pem-path-'));
+ const file = path.join(directory, 'cross-segment.har');
+ await writeFile(file, text);
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const approved = await handlers.get('analyze_approved_files')({ detailLimit: 5 });
+ assertCrossSegmentMcpResult(approved, fixtureValue);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ await assert.rejects(handlers.get('analyze_approved_files')({}), { code: 'FILE_APPROVAL_REQUIRED' });
+});
+
+test('registered inline and approved handlers fail closed for adversarial PEM paths and methods', async () => {
+ const handlers = new Map();
+ preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } });
+ const fixtureValue = pathRedactionHar();
+ const text = JSON.stringify(fixtureValue.value);
+ const inline = await handlers.get('analyze_inline')({ har: text, detailLimit: 20 });
+ assertPathRedactionMcpResult(inline, fixtureValue);
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-adversarial-path-'));
+ const file = path.join(directory, 'adversarial-path.har');
+ await writeFile(file, text);
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const approved = await handlers.get('analyze_approved_files')({ detailLimit: 20 });
+ assertPathRedactionMcpResult(approved, fixtureValue);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ await assert.rejects(handlers.get('analyze_approved_files')({}), { code: 'FILE_APPROVAL_REQUIRED' });
+});
+
+test('two maximum-detail reports stay under 512 KiB and retain full totals', async () => {
+ const result = await preload.analyzeInlineForMcp({ har: responsePressureHar('left'), compareHar: responsePressureHar('right'), detailLimit: 50 });
+ assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES);
+ assert.equal(result.reports[0].totals.slow, 50);
+ assert.equal(result.reports[1].totals.security, 50);
+ assert.equal(result.requestedDetailLimit, 50);
+ assert.equal(result.responseTruncated, false);
+ assert.equal(result.detailLimit, result.requestedDetailLimit);
+ assert.ok(Buffer.byteLength(result.reports[0].details.slow[0].method, 'utf8') <= 1024);
+});
+
+test('MCP handlers expose stable path-free errors for malformed HAR documents', async () => {
+ await assert.rejects(
+ preload.analyzeInlineForMcp({ har: '{}' }),
+ stableMachineError('HAR_ANALYSIS_FAILED', 'HAR analysis failed.', 'log.entries', 'HAR 必须')
+ );
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-malformed-approved-'));
+ const file = path.join(directory, 'malformed.har');
+ await writeFile(file, '{}');
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ await assert.rejects(
+ preload.analyzeApprovedForMcp({}),
+ stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', directory, file, 'log.entries', 'HAR 必须')
+ );
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+});
+
+test('approved MCP handler maps descriptor read failures without leaking paths and revokes the grant', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'har-read-failure-'));
+ const file = path.join(directory, 'failure.har');
+ await writeFile(file, inlineHar(1));
+ preload.__testGrant([file]);
+ const record = preload.__testGrants()[0];
+ const originalRead = fs.readSync;
+ fs.readSync = () => { throw new Error(`EIO while reading ${file}`); };
+ try {
+ await assert.rejects(
+ preload.analyzeApprovedForMcp({}),
+ stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', directory, file, 'EIO')
+ );
+ } finally {
+ fs.readSync = originalRead;
+ }
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ await assert.rejects(preload.analyzeApprovedForMcp({}), { code: 'FILE_APPROVAL_REQUIRED' });
+});
+
+test('strict validation rejects paths, hostile prototypes and unsafe bounds', async () => {
+ await assert.rejects(preload.analyzeApprovedForMcp({ path: fixture }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.analyzeInlineForMcp({ har: '{}', detailLimit: 51 }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.analyzeInlineForMcp({ har: 'x'.repeat(512 * 1024 + 1) }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.analyzeInlineForMcp(JSON.parse('{"har":"{}","__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.analyzeInlineForMcp(Object.assign(Object.create({ har: inlineHar(1) }), { detailLimit: 1 })), { code: 'INVALID_TOOL_INPUT' });
+});
diff --git a/plugins/har-doctor/test/path-redaction-fixture.mjs b/plugins/har-doctor/test/path-redaction-fixture.mjs
new file mode 100644
index 00000000..da7fa139
--- /dev/null
+++ b/plugins/har-doctor/test/path-redaction-fixture.mjs
@@ -0,0 +1,86 @@
+const pathEncode = encodeURIComponent;
+
+function query(token) {
+ return `?safe=one&safe=two&${token}%ZZ=ignored#fragment-${token}`;
+}
+
+export function pathRedactionHar() {
+ const queryToken = `github_pat_${'Q'.repeat(24)}`;
+ const invalidAfterToken = `ghp_${'I'.repeat(36)}`;
+ const nestedMethod = 'METHOD_PREFIX-----BEGIN PRIVATE KEY-----OUTER_METHOD_BODY-----BEGIN PRIVATE KEY-----INNER_METHOD_BODY-----END PRIVATE KEY-----METHOD_OUTER_TAIL_SECRET-----END PRIVATE KEY-----VISIBLE_METHOD_NESTED';
+ const mismatchedMethod = '-----BEGIN RSA PRIVATE KEY-----METHOD_RSA_BODY-----END PRIVATE KEY-----METHOD_MISMATCH_TAIL-----END RSA PRIVATE KEY-----VISIBLE_METHOD_MATCHED';
+ const unicodeUnclosedMethod = `${'ß'.repeat(52)}-----END PRIVATE KEY-----METHOD_STRAY_VISIBLE-----BEGIN PRIVATE KEY-----METHOD_UNICODE_UNCLOSED_BODY`;
+ const unicodeCompleteMethod = '-----BEGIN PRIVATE KEY-----METHOD_UNICODE_ß_BODY-----END PRIVATE KEY-----VISIBLE_METHOD_UNICODE';
+ const sequentialMethod = '-----BEGIN EC PRIVATE KEY-----METHOD_FIRST_BODY-----END EC PRIVATE KEY-----VISIBLE_METHOD_FIRST-----BEGIN DSA PRIVATE KEY-----METHOD_SECOND_BODY-----END DSA PRIVATE KEY-----VISIBLE_METHOD_SECOND';
+ const invalidComplete = `not a url/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----INVALID_COMPLETE_BODY')}/INVALID_COMPLETE_TAIL_A/INVALID_COMPLETE_TAIL_B/${pathEncode(`-----END PRIVATE KEY-----VISIBLE_INVALID_${invalidAfterToken}`)}${query(queryToken)}`;
+ const invalidTruncated = `not a url/safe%2Fboundary/${pathEncode('-----BEGIN RSA PRIVATE KEY-----INVALID_TRUNCATED_BODY')}/INVALID_TRUNCATED_TAIL_A/INVALID_TRUNCATED_TAIL_B${query(queryToken)}`;
+ const unicodeUnclosed = `https://safe.test/safe%2Fboundary/${pathEncode(`${'ß'.repeat(52)}-----END PRIVATE KEY-----STRAY_VISIBLE-----BEGIN PRIVATE KEY-----UNICODE_UNCLOSED_BODY`)}/UNICODE_UNCLOSED_TAIL${query(queryToken)}`;
+ const unicodeComplete = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----UNICODE_ß_BODY')}/${pathEncode('-----END PRIVATE KEY-----VISIBLE_UNICODE')}${query(queryToken)}`;
+ const malformedSame = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----')}%ZZMALFORMED_SAME_SECRET/MALFORMED_SAME_AFTER${query(queryToken)}`;
+ const malformedCross = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----VALID_PREFIX')}/%ZZMALFORMED_CROSS_SECRET/MALFORMED_CROSS_AFTER${query(queryToken)}`;
+ const mismatchedEnd = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN RSA PRIVATE KEY-----MISMATCH_BODY_ß')}/${pathEncode('-----END PRIVATE KEY-----MISMATCH_AFTER_WRONG')}/MISMATCH_STILL_PRIVATE/${pathEncode('-----END RSA PRIVATE KEY-----VISIBLE_MATCHED')}${query(queryToken)}`;
+ const nestedSameLabel = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----OUTER_BODY')}/${pathEncode('-----BEGIN PRIVATE KEY-----INNER_BODY')}/${pathEncode('-----END PRIVATE KEY-----NESTED_OUTER_TAIL')}/OUTER_STILL_PRIVATE/${pathEncode('-----END PRIVATE KEY-----VISIBLE_NESTED')}${query(queryToken)}`;
+ const sequential = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN EC PRIVATE KEY-----FIRST_BODY')}/${pathEncode('-----END EC PRIVATE KEY-----VISIBLE_FIRST')}/${pathEncode('-----BEGIN DSA PRIVATE KEY-----SECOND_BODY')}/${pathEncode('-----END DSA PRIVATE KEY-----VISIBLE_SECOND')}${query(queryToken)}`;
+ const surrogate = `not a url/safe%2Fboundary/\uD800/SURROGATE_VISIBLE?safe=one&safe=two&${queryToken}%ZZ=ignored&\uD800=ignored#fragment-${queryToken}`;
+ const cases = [
+ { name: 'invalid-complete', url: invalidComplete, method: nestedMethod, valid: false, visible: [] },
+ { name: 'invalid-truncated', url: invalidTruncated, method: mismatchedMethod, valid: false, visible: [] },
+ { name: 'unicode-unclosed', url: unicodeUnclosed, method: unicodeUnclosedMethod, valid: true, visible: ['STRAY_VISIBLE'] },
+ { name: 'unicode-complete', url: unicodeComplete, method: unicodeCompleteMethod, valid: true, visible: ['VISIBLE_UNICODE'] },
+ { name: 'malformed-same', url: malformedSame, valid: true, visible: [] },
+ { name: 'malformed-cross', url: malformedCross, valid: true, visible: [] },
+ { name: 'mismatched-end', url: mismatchedEnd, valid: true, visible: ['VISIBLE_MATCHED'] },
+ { name: 'nested-same-label', url: nestedSameLabel, valid: true, visible: ['VISIBLE_NESTED'] },
+ { name: 'sequential', url: sequential, method: sequentialMethod, valid: true, visible: ['VISIBLE_FIRST', 'VISIBLE_SECOND'] },
+ { name: 'surrogate', url: surrogate, valid: false, visible: [] }
+ ];
+ const forbidden = [
+ queryToken,
+ invalidAfterToken,
+ '-----BEGIN',
+ '-----END',
+ 'INVALID_COMPLETE_BODY',
+ 'INVALID_COMPLETE_TAIL_A',
+ 'INVALID_COMPLETE_TAIL_B',
+ 'INVALID_TRUNCATED_BODY',
+ 'INVALID_TRUNCATED_TAIL_A',
+ 'INVALID_TRUNCATED_TAIL_B',
+ 'UNICODE_UNCLOSED_BODY',
+ 'UNICODE_UNCLOSED_TAIL',
+ 'UNICODE_ß_BODY',
+ 'MALFORMED_SAME_SECRET',
+ 'MALFORMED_SAME_AFTER',
+ 'MALFORMED_CROSS_SECRET',
+ 'MALFORMED_CROSS_AFTER',
+ 'MISMATCH_BODY_ß',
+ 'MISMATCH_AFTER_WRONG',
+ 'MISMATCH_STILL_PRIVATE',
+ 'OUTER_BODY',
+ 'INNER_BODY',
+ 'NESTED_OUTER_TAIL',
+ 'OUTER_STILL_PRIVATE',
+ 'FIRST_BODY',
+ 'SECOND_BODY',
+ 'OUTER_METHOD_BODY',
+ 'INNER_METHOD_BODY',
+ 'METHOD_OUTER_TAIL_SECRET',
+ 'METHOD_RSA_BODY',
+ 'METHOD_MISMATCH_TAIL',
+ 'METHOD_UNICODE_UNCLOSED_BODY',
+ 'METHOD_UNICODE_ß_BODY',
+ 'METHOD_FIRST_BODY',
+ 'METHOD_SECOND_BODY',
+ '\uD800',
+ '\\ud800'
+ ];
+ const value = {
+ log: {
+ entries: cases.map(({ url, method }, index) => ({
+ time: 1800 + index,
+ request: { url, method: method || 'GET', headers: [] },
+ response: { status: 500, bodySize: 10, headers: [], content: { mimeType: 'application/json' } }
+ }))
+ }
+ };
+ return { value, cases, forbidden, queryToken };
+}
diff --git a/plugins/har-doctor/test/privacy-lifecycle.test.mjs b/plugins/har-doctor/test/privacy-lifecycle.test.mjs
new file mode 100644
index 00000000..55601fcd
--- /dev/null
+++ b/plugins/har-doctor/test/privacy-lifecycle.test.mjs
@@ -0,0 +1,165 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+import { analyze, classifyPii, redact, redactUrl } from '../src/core/analyze.js';
+
+const require = createRequire(import.meta.url);
+const preload = require('../src/preload/index.cjs');
+const fixture = fileURLToPath(new URL('./fixtures/sample.har', import.meta.url));
+const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+test('privacy projection stably anonymizes hostnames and path identities', () => {
+ const values = [
+ 'internal.example.com',
+ 'alice',
+ 'acme-corp',
+ '18812345678',
+ '11010519491231002X',
+ '192.168.10.24',
+ 'alice@example.com',
+ '1234567890abcdef1234567890abcdef',
+ 'tenant-omega'
+ ];
+ const raw = `https://internal.example.com/api/v1/users/alice/tenants/acme-corp/phones/18812345678/ids/11010519491231002X/ips/192.168.10.24/emails/alice@example.com/orders/1234567890abcdef1234567890abcdef/tenant-omega?access_token=do-not-export`;
+ const clean = redactUrl(raw);
+ const repeated = redactUrl(raw);
+ const changedHost = redactUrl(raw.replace('internal.example.com', 'staging.internal.example.com'));
+ const parsed = new URL(clean);
+ assert.equal(clean, repeated);
+ assert.notEqual(parsed.hostname, new URL(changedHost).hostname);
+ assert.match(parsed.hostname, /^host-[a-f0-9]{32}\.invalid$/);
+ assert.match(decodeURIComponent(parsed.pathname), /^\/api\/v1\/users\/\[user-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/tenants\/\[tenant-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/phones\/\[phone-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/ids\/\[identity-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/ips\/\[ip-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/emails\/\[email-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/orders\/\[id-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/\[tenant-/);
+ for (const value of values) assert.equal(clean.includes(value), false, `URL leaked ${value}`);
+ const report = analyze({ log: { entries: [{ time: 1500, request: { url: raw, method: 'GET' }, response: { status: 500 } }] } });
+ const exported = JSON.stringify(redact(report));
+ for (const value of values) assert.equal(exported.includes(value), false, `report leaked ${value}`);
+ assert.equal(exported.includes('do-not-export'), false);
+});
+
+test('privacy classifier covers query names, short aliases, tenants, compressed IPv6 and invalid bases', () => {
+ const secrets = ['alice', 'bob', 'acme', '2001:db8::7', 'internal.example.com', 'alice@example.com', '18812345678', '11010519491231002X'];
+ const raw = 'https://internal.example.com/u/alice/@bob/t/acme/ips/2001:db8::7?alice@example.com=value&2001%3Adb8%3A%3A7=value&safe=value&internal.example.com=value&alice=value&bob=value&acme=value';
+ const clean = redactUrl(raw);
+ const parsed = new URL(clean);
+ for (const secret of secrets) assert.equal(clean.includes(secret), false, `URL leaked ${secret}`);
+ assert.match(decodeURIComponent(parsed.pathname), /\/u\/\[user-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/\[user-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/t\/\[tenant-/);
+ assert.match(decodeURIComponent(parsed.pathname), /\/ips\/\[ip-/);
+ assert.ok([...parsed.searchParams.keys()].every((key) => /^\[query-[a-f0-9]{32}\]$/.test(key)));
+ assert.equal(classifyPii('2001:db8::7'), 'ip');
+ assert.equal(classifyPii('@bob'), 'user');
+ assert.equal(classifyPii('acme', 't'), 'tenant');
+
+ const invalid = redactUrl('internal.example.com/u/alice/2001:db8::7?alice@example.com=value');
+ assert.equal(invalid.includes('internal.example.com'), false);
+ assert.equal(invalid.includes('alice'), false);
+ assert.equal(invalid.includes('2001:db8'), false);
+ assert.equal(invalid.split('?')[0].split('/').length, 'internal.example.com/u/alice/2001:db8::7'.split('/').length);
+});
+
+test('method and MIME projections cannot echo hostile HAR fields, and ids remain distinct', () => {
+ const left = redactUrl('https://private.example/u/alice');
+ const right = redactUrl('https://private.example/u/bob');
+ assert.notEqual(left, right);
+ const report = analyze({ log: { entries: [{
+ time: 1,
+ request: { url: 'https://internal.example.com/u/alice', method: 'GET alice@example.com 2001:db8::7' },
+ response: { status: 500, content: { mimeType: 'application/alice@example.com; token=do-not-export' } }
+ }] } });
+ const surface = JSON.stringify(redact(report));
+ for (const value of ['internal.example.com', 'alice', 'alice@example.com', '2001:db8::7', 'do-not-export']) assert.equal(surface.includes(value), false, `report leaked ${value}`);
+ assert.equal(report.errors[0].method, 'OTHER');
+ assert.deepEqual(Object.keys(report.types), ['unknown']);
+});
+
+test('file grants close proactively once, reset on replacement, and cancel for all session exits', async () => {
+ preload.__testClear();
+ preload.__testSetGrantTtl(100);
+ try {
+ preload.__testGrant([fixture]);
+ const first = preload.__testGrants()[0];
+ assert.equal(preload.__testHasGrantTimer(), true);
+ await wait(60);
+ preload.__testGrant([fixture]);
+ const replacement = preload.__testGrants()[0];
+ assert.equal(first.closed, true);
+ await wait(60);
+ assert.doesNotThrow(() => fs.fstatSync(replacement.fd));
+ await wait(70);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testHasGrantTimer(), false);
+ assert.throws(() => fs.fstatSync(replacement.fd), { code: 'EBADF' });
+
+ preload.__testSetGrantTtl(1000);
+ preload.__testGrant([fixture]);
+ const consumed = preload.__testGrants()[0];
+ preload.readGranted();
+ assert.equal(preload.__testHasGrantTimer(), false);
+ assert.throws(() => fs.fstatSync(consumed.fd), { code: 'EBADF' });
+
+ let onPluginOut;
+ const bridge = preload.bridge({ onPluginOut(listener) { onPluginOut = listener; } });
+ preload.__testGrant([fixture]);
+ const exited = preload.__testGrants()[0];
+ onPluginOut();
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testHasGrantTimer(), false);
+ assert.throws(() => fs.fstatSync(exited.fd), { code: 'EBADF' });
+
+ let resolveDialog;
+ const choosing = preload.bridge({
+ onPluginOut(listener) { onPluginOut = listener; },
+ showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; })
+ }).choose();
+ onPluginOut();
+ resolveDialog({ filePaths: [fixture] });
+ await assert.rejects(choosing, { code: 'SESSION_EXPIRED' });
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testHasGrantTimer(), false);
+ } finally {
+ preload.__testClear();
+ preload.__testResetGrantTtl();
+ }
+});
+
+test('failed descriptor close revokes authorization and retries without exposing the file path', () => {
+ preload.__testClear();
+ let failOnce = true;
+ preload.__testSetCloseSync((fd) => {
+ if (failOnce) {
+ failOnce = false;
+ const error = new Error('simulated close failure');
+ error.code = 'EIO';
+ throw error;
+ }
+ return fs.closeSync(fd);
+ });
+ try {
+ preload.__testGrant([fixture]);
+ const record = preload.__testGrants()[0];
+ preload.__testClear();
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testPendingCloses().length, 1);
+ assert.deepEqual(Object.keys(preload.__testPendingCloses()[0]).sort(), ['closeFailed', 'closed', 'fd']);
+ assert.equal(Object.hasOwn(preload.__testPendingCloses()[0], 'real'), false);
+ assert.doesNotThrow(() => fs.fstatSync(record.fd));
+ assert.throws(() => preload.readGranted(), { code: 'FILE_APPROVAL_REQUIRED' });
+ preload.__testClear();
+ assert.equal(preload.__testPendingCloses().length, 0);
+ assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' });
+ } finally {
+ preload.__testResetCloseSync();
+ preload.__testRetryPendingCloses();
+ preload.__testClear();
+ }
+});