diff --git a/plugins/plugin-guard/.gitignore b/plugins/plugin-guard/.gitignore
new file mode 100644
index 00000000..1eae0cf6
--- /dev/null
+++ b/plugins/plugin-guard/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/plugins/plugin-guard/CHANGELOG.md b/plugins/plugin-guard/CHANGELOG.md
new file mode 100644
index 00000000..8d2b69d4
--- /dev/null
+++ b/plugins/plugin-guard/CHANGELOG.md
@@ -0,0 +1,14 @@
+# 更新日志
+
+## 0.1.0
+
+- 提供只读的插件清单、能力与风险扫描器。
+- 为 ZTools 2.4+ 增加有界、需人工授权的原生 MCP 扫描工具,并在旧版宿主上安全降级。
+- 在保留完整发现项总数的同时限制 MCP 证据体积,并在不泄露路径的前提下统一处理已授权目录不可用错误。
+- 人工界面最多保留 5,000 条证据,保留完整汇总数,并在状态和复制报告中明确标记截断。
+- 将界面授权绑定到规范目录身份,在人工或 MCP 扫描前后复验,并拒绝同路径替换。
+- 对每个 MCP 返回字符串应用统一的最终脱敏器,覆盖 GitHub/OpenAI/AWS/Bearer/JWT 以及完整或截断的 PEM 凭据。
+- 通过已保留证据的 `hasMore`、`nextOffset` 和有效的 `offset=400` 空终止页,让全部 400 条 MCP 发现项可分页访问。
+- 根目录使用 ZTools 可直接加载的源码入口,CI 仍打包自包含的 `dist` 清单。
+- 在未压缩 `dist` 超过 14.5 MB 时拒绝发布,并打印精确的已验证字节数。
+- 将人类界面、状态提示、目录选择对话框、风险说明和导出报告完整本地化为简体中文。
diff --git a/plugins/plugin-guard/README.md b/plugins/plugin-guard/README.md
new file mode 100644
index 00000000..2d864d85
--- /dev/null
+++ b/plugins/plugin-guard/README.md
@@ -0,0 +1,25 @@
+# 插件安全体检
+
+界面、状态提示、文件对话框和导出的 Markdown 标题均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。
+
+插件安全体检会读取用户选择的插件目录,报告清单错误、高风险能力模式、文件上限、路径风险与已脱敏的证据。它不会修改受扫描目录,也不会跟随符号链接。受审计的文本会在全局扫描字节上限内完整读取,不会静默截断。
+
+扫描器限制遍历深度、文件数量和总字节数;未知桥接字段会按失败关闭处理。它会统计每一个发现项,但人工界面最多保留 5,000 条证据,显示“已展示数 / 总数”,并在证据被截断时标记 JSON 和 Markdown 导出。报告可导出为 JSON 或 Markdown。首个版本故意不开启 ZIP 检查,因为安全的归档解析器应在与专用归档工具相同的限制边界内运行。
+
+扫描器结合使用 lstat、realpath 和经校验的打开句柄,以缩小文件系统竞态窗口。同一账户下的进程仍可在检查间隙替换目录树;纯跨平台 Node 无法在不使用平台特定文件系统能力的情况下彻底消除这类竞态,因此应在发布前立即重新执行扫描。
+
+## 开发与打包
+
+根目录 `plugin.json` 直接指向 `src/ui/index.html`、`preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发项目无需解析 `development` 覆盖项即可加载源码包。仓库 CI 仍会执行包内的 `build` 脚本并打包 `dist`:源入口目录不包含嵌套清单,构建则会生成自包含的 `dist/plugin.json`,其入口为 `index.html`、`preload/index.cjs` 和 `logo.svg`。
+
+`npm run verify-dist` 会递归统计未压缩的 `dist` 目录,打印精确字节数,并在超过 14.5 MB(14,500,000 字节)安全上限时失败。
+
+## Agent / MCP 使用
+
+在 ZTools 2.4+ 中,清单工具 `scan_approved` 会以 `plugin_guard_scan_approved` 的完整名称暴露给 Agent。Agent 无法提供或发现路径:必须先由用户在界面中选择插件目录,该只读授权会在五分钟后或插件退出时失效。没有有效界面授权时,工具以 `WORKSPACE_APPROVAL_REQUIRED` 失败。旧宿主没有 `registerTool` 时仍会保留原有人工界面,仅省略 Agent 工具。
+
+工具接受 `report=json|markdown|both`、范围为 0–400 的 `offset` 和范围为 1–200 的 `limit`。Markdown 由与界面相同的已审计核心格式化器生成。MCP 扫描使用更紧的上限:在统计每一个发现项的同时,最多保留 400 条证据,因此分页可使用完整总数,而不会构建无上限的响应。`hasMore` 和 `nextOffset` 仅描述已保留证据;`offset=400` 是有效的空终止页,即使 `totals.findings` 仍可能大得多。发现项消息和文件证据分别按 512 个 UTF-8 字节裁剪;过大的页会继续缩减并设置 `responseTruncated=true`,直到序列化响应不超过 512 KiB。页面不会缩减到零进度,汇总总数也保持不变。Agent 输出会移除绝对根目录和原始入口清单;在序列化前,单一最终脱敏器会覆盖每个返回字符串,包括具有可靠前缀的 GitHub/OpenAI/AWS 凭据、带标签的 AWS 密钥、Bearer/JWT 值以及完整或截断的 PEM 私钥。
+
+界面授权保存所选目录的规范真实路径和跨平台文件系统身份,而不只是路径字符串。人工扫描和 MCP 扫描都会在读取前后立即复验该身份;如果目录被删除、移动、在同一路径下替换或变得无法验证,授权会被撤销,MCP 工具返回 `APPROVED_DIRECTORY_UNAVAILABLE`,且不会暴露路径。在无法使用有效设备号/inode 组合的系统上,会通过出生/创建/变更/修改时间元数据按失败关闭处理。宿主将 MCP 请求体限制为 1 MiB;大型目录内容不会放入请求,必须通过人工文件对话框授权。
+
+运行 `npm test`、`npm run build`,再运行 `npm run verify-dist`。在真实 Windows、macOS 和 Linux ZTools 宿主中的测试仍待完成。
diff --git a/plugins/plugin-guard/logo.svg b/plugins/plugin-guard/logo.svg
new file mode 100644
index 00000000..121113f2
--- /dev/null
+++ b/plugins/plugin-guard/logo.svg
@@ -0,0 +1 @@
+
diff --git a/plugins/plugin-guard/package-lock.json b/plugins/plugin-guard/package-lock.json
new file mode 100644
index 00000000..912e4ecd
--- /dev/null
+++ b/plugins/plugin-guard/package-lock.json
@@ -0,0 +1 @@
+{"name":"plugin-guard","version":"0.1.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"plugin-guard","version":"0.1.0","engines":{"node":">=16"}}}}
diff --git a/plugins/plugin-guard/package.json b/plugins/plugin-guard/package.json
new file mode 100644
index 00000000..ae4bec93
--- /dev/null
+++ b/plugins/plugin-guard/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "plugin-guard",
+ "version": "0.1.0",
+ "private": true,
+ "type": "commonjs",
+ "engines": {
+ "node": ">=16"
+ },
+ "scripts": {
+ "test": "node --test",
+ "build": "node --test && node scripts/build.mjs && node scripts/verify-dist.mjs",
+ "verify-dist": "node scripts/verify-dist.mjs"
+ }
+}
diff --git a/plugins/plugin-guard/plugin.json b/plugins/plugin-guard/plugin.json
new file mode 100644
index 00000000..96a2de1d
--- /dev/null
+++ b/plugins/plugin-guard/plugin.json
@@ -0,0 +1,28 @@
+{
+ "name": "plugin-guard",
+ "title": "插件安全体检",
+ "version": "0.1.0",
+ "description": "面向 ZTools 插件的只读能力与风险扫描器。",
+ "author": "harris",
+ "main": "src/ui/index.html",
+ "logo": "logo.svg",
+ "preload": "preload/index.cjs",
+ "platform": ["darwin", "win32", "linux"],
+ "categories": ["development", "system"],
+ "features": [{"code": "inspect-plugin", "explain": "只读检查所选插件目录,不修改其中内容。", "icon": "logo.svg", "cmds": ["插件体检", "插件安全扫描"]}],
+ "tools": {
+ "scan_approved": {
+ "title": "扫描已授权的 ZTools 插件目录",
+ "description": "只读扫描最近五分钟内由用户在插件界面选择的目录;不接受路径参数。",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "report": {"type": "string", "enum": ["json", "markdown", "both"]},
+ "offset": {"type": "integer", "minimum": 0, "maximum": 400},
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200}
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/plugin-guard/preload/index.cjs b/plugins/plugin-guard/preload/index.cjs
new file mode 100644
index 00000000..c9260e1b
--- /dev/null
+++ b/plugins/plugin-guard/preload/index.cjs
@@ -0,0 +1,291 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+const TOOL_NAMES = Object.freeze({ scan: 'scan_approved' });
+const GRANT_TTL_MS = 5 * 60 * 1000;
+const MCP_FINDING_LIMIT = 400;
+const MCP_FEATURE_LIMIT = 200;
+const MCP_RESPONSE_BYTES = 512 * 1024;
+const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+const registeredHosts = new WeakSet();
+let selectedRoot = null;
+let guardPromise;
+let sessionEpoch = 0;
+
+function invalid(message) { return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); }
+function approvalRequired() { return Object.assign(new Error('请先在插件安全体检界面中选择插件目录;授权仅保留五分钟。'), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); }
+function approvedDirectoryUnavailable() { return Object.assign(new Error('已授权的插件目录不可用,请在界面中重新选择。'), { code: 'APPROVED_DIRECTORY_UNAVAILABLE' }); }
+function sessionExpired() { return Object.assign(new Error('插件会话已结束,请重新打开后选择插件目录。'), { code: 'SESSION_EXPIRED' }); }
+function expireSession() { sessionEpoch += 1; selectedRoot = null; }
+
+function selectedPath(result) {
+ if (typeof result === 'string') return result;
+ if (Array.isArray(result)) return result[0];
+ return result && result.filePaths && result.filePaths[0];
+}
+
+function statValue(value) {
+ if (typeof value === 'bigint') return value.toString();
+ return Number.isFinite(value) ? String(value) : null;
+}
+
+function directoryIdentity(info) {
+ return Object.freeze({
+ dev: statValue(info.dev),
+ ino: statValue(info.ino),
+ birthtimeMs: statValue(info.birthtimeMs),
+ ctimeMs: statValue(info.ctimeMs),
+ mtimeMs: statValue(info.mtimeMs),
+ mode: statValue(info.mode)
+ });
+}
+
+function hasStableFileId(identity) {
+ return identity.dev !== null && identity.ino !== null && identity.ino !== '0';
+}
+
+function sameDirectoryIdentity(left, right) {
+ const stableFileId = hasStableFileId(left) && hasStableFileId(right);
+ if (stableFileId && (left.dev !== right.dev || left.ino !== right.ino)) return false;
+ const fallbackFields = ['birthtimeMs', 'ctimeMs', 'mtimeMs', 'mode'];
+ if (!stableFileId && !fallbackFields.some((key) => left[key] !== null && left[key] !== '0')) return false;
+ return fallbackFields.every((key) => left[key] === right[key]);
+}
+
+function realpathDirectory(directory) {
+ const requested = path.resolve(String(directory));
+ const requestedLink = fs.lstatSync(requested);
+ if (requestedLink.isSymbolicLink() || !requestedLink.isDirectory()) throw new TypeError('请选择插件目录,而不是符号链接或文件。');
+ const real = typeof fs.realpathSync.native === 'function' ? fs.realpathSync.native(requested) : fs.realpathSync(requested);
+ const realLink = fs.lstatSync(real);
+ const info = fs.statSync(real);
+ if (realLink.isSymbolicLink() || !realLink.isDirectory() || !info.isDirectory()) throw new TypeError('请选择插件目录,而不是符号链接或文件。');
+ const requestedIdentity = directoryIdentity(requestedLink);
+ const realIdentity = directoryIdentity(realLink);
+ const identity = directoryIdentity(info);
+ if (!sameDirectoryIdentity(requestedIdentity, realIdentity) || !sameDirectoryIdentity(realIdentity, identity)) throw new Error('插件目录在验证授权时发生变化。');
+ return { real, identity };
+}
+
+function createDirectoryGrant(directory, expires = Date.now() + GRANT_TTL_MS) {
+ const captured = realpathDirectory(directory);
+ return Object.freeze({ path: captured.real, real: captured.real, identity: captured.identity, expires, source: 'ui' });
+}
+
+function revokeGrant(grant) {
+ if (!grant || selectedRoot === grant) selectedRoot = null;
+}
+
+function currentApprovedGrant() {
+ if (!selectedRoot || selectedRoot.source !== 'ui' || selectedRoot.expires < Date.now()) {
+ selectedRoot = null;
+ throw approvalRequired();
+ }
+ return selectedRoot;
+}
+
+function verifyApprovedGrant(grant) {
+ try {
+ if (!grant || selectedRoot !== grant) throw new Error('已授权目录的授权信息发生变化。');
+ const current = realpathDirectory(grant.path);
+ if (current.real !== grant.real || !sameDirectoryIdentity(grant.identity, current.identity)) throw new Error('已授权目录的身份信息发生变化。');
+ return current.real;
+ } catch {
+ revokeGrant(grant);
+ throw approvedDirectoryUnavailable();
+ }
+}
+
+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 validateToolInput(input) {
+ validateObject(input, new Set(['report', 'offset', 'limit']), '工具输入');
+ if (input.report !== undefined && !['json', 'markdown', 'both'].includes(input.report)) throw invalid('report 必须是 json、markdown 或 both。');
+ if (input.offset !== undefined && (!Number.isInteger(input.offset) || input.offset < 0 || input.offset > MCP_FINDING_LIMIT)) throw invalid(`offset 必须是 0 到 ${MCP_FINDING_LIMIT} 之间的整数。`);
+ if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 200)) throw invalid('limit 必须是 1 到 200 之间的整数。');
+ return { report: input.report || 'json', offset: input.offset || 0, limit: input.limit || 50 };
+}
+
+function loadGuard() {
+ if (!guardPromise) {
+ const packaged = path.join(__dirname, '..', 'core', 'guard.mjs');
+ const corePath = fs.existsSync(packaged) ? packaged : path.join(__dirname, '..', 'src', 'core', 'guard.mjs');
+ guardPromise = import(pathToFileURL(corePath).href);
+ }
+ return guardPromise;
+}
+
+function clip(value, max = 2048) {
+ const text = String(value ?? '');
+ if (Buffer.byteLength(text, 'utf8') <= max) return text;
+ const suffix = '…[truncated]';
+ const budget = Math.max(0, max - 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 sanitizedText(value, sanitize) {
+ return sanitize(String(value ?? ''));
+}
+
+function safeFinding(value, sanitize) {
+ return { level: clip(sanitizedText(value?.level, sanitize), 32), code: clip(sanitizedText(value?.code, sanitize), 128), message: clip(sanitizedText(value?.message, sanitize), 512), ...(value?.file ? { file: clip(sanitizedText(value.file, sanitize), 512) } : {}) };
+}
+
+function sanitizeOutput(value, sanitize, ancestors = new WeakSet()) {
+ if (typeof value === 'string') return sanitize(value);
+ if (value === null || typeof value !== 'object') return value;
+ if (ancestors.has(value)) return '[redacted]';
+ ancestors.add(value);
+ const output = Array.isArray(value) ? [] : Object.create(null);
+ if (Array.isArray(value)) {
+ for (const item of value) output.push(sanitizeOutput(item, sanitize, ancestors));
+ } else {
+ for (const [key, item] of Object.entries(value)) output[key] = sanitizeOutput(item, sanitize, ancestors);
+ }
+ ancestors.delete(value);
+ return output;
+}
+
+function paginateReport(report, { offset, limit }, sanitize = (value) => String(value ?? '')) {
+ const issues = Array.isArray(report.issues) ? report.issues : [];
+ const risks = Array.isArray(report.risks) ? report.risks : [];
+ const all = [...issues.map((value) => ({ kind: 'issue', value })), ...risks.map((value) => ({ kind: 'risk', value }))];
+ const page = all.slice(offset, offset + limit);
+ const pageIssues = page.filter((item) => item.kind === 'issue').map((item) => safeFinding(item.value, sanitize));
+ const pageRisks = page.filter((item) => item.kind === 'risk').map((item) => safeFinding(item.value, sanitize));
+ const featureList = Array.isArray(report.manifest?.features) ? report.manifest.features : [];
+ const totalIssues = Number.isFinite(Number(report.findingTotals?.issues)) ? Number(report.findingTotals.issues) : issues.length;
+ const totalRisks = Number.isFinite(Number(report.findingTotals?.risks)) ? Number(report.findingTotals.risks) : risks.length;
+ const totalFindings = totalIssues + totalRisks;
+ const totalFeatures = Number.isFinite(Number(report.manifestFeatureTotal)) ? Number(report.manifestFeatureTotal) : featureList.length;
+ const json = {
+ manifest: report.manifest ? { name: clip(sanitizedText(report.manifest.name, sanitize), 256), version: clip(sanitizedText(report.manifest.version, sanitize), 128), features: featureList.slice(0, 200).map((feature) => ({ code: clip(sanitizedText(feature?.code, sanitize), 128) })) } : null,
+ files: Number(report.files) || 0,
+ bytes: Number(report.bytes) || 0,
+ scannedAt: clip(sanitizedText(report.scannedAt, sanitize), 64),
+ issues: pageIssues,
+ risks: pageRisks,
+ totals: { issues: totalIssues, risks: totalRisks, findings: totalFindings, retainedFindings: all.length, features: totalFeatures },
+ pagination: { offset, limit, returned: page.length, nextOffset: offset + page.length, hasMore: offset + page.length < all.length }
+ };
+ return json;
+}
+
+function serializedBytes(value) { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
+
+function buildMcpResponse(report, options, core) {
+ if (typeof core?.maskSecrets !== 'function') throw Object.assign(new Error('MCP 输出脱敏器不可用。'), { code: 'MCP_SANITIZER_UNAVAILABLE' });
+ const sanitize = core.maskSecrets;
+ const retainedFindings = (Array.isArray(report.issues) ? report.issues.length : 0) + (Array.isArray(report.risks) ? report.risks.length : 0);
+ const requestedPageCount = Math.min(options.limit, Math.max(0, retainedFindings - options.offset));
+ let effectiveLimit = requestedPageCount;
+ while (true) {
+ const json = paginateReport(report, { offset: options.offset, limit: effectiveLimit }, sanitize);
+ const responseTruncated = json.pagination.returned < requestedPageCount;
+ json.pagination = { ...json.pagination, requestedLimit: options.limit, requestedPageCount, responseTruncated };
+ const markdown = core.toMarkdown({ scannedAt: json.scannedAt, files: json.files, bytes: json.bytes, issues: json.issues, risks: json.risks, findingTotals: { issues: json.totals.issues, risks: json.totals.risks, findings: json.totals.findings }, retainedFindings: json.pagination.returned, findingsTruncated: json.pagination.offset > 0 || json.pagination.hasMore });
+ const meta = { totals: json.totals, pagination: json.pagination, responseTruncated };
+ const response = options.report === 'markdown' ? { ...meta, markdown } : options.report === 'both' ? { ...meta, json, markdown } : { ...meta, json };
+ const safeResponse = sanitizeOutput(response, sanitize);
+ if (serializedBytes(safeResponse) <= MCP_RESPONSE_BYTES) return safeResponse;
+ if (effectiveLimit <= 1) throw Object.assign(new Error('MCP 响应无法满足安全预算。'), { code: 'MCP_RESPONSE_TOO_LARGE' });
+ effectiveLimit = Math.max(1, Math.floor(effectiveLimit / 2));
+ }
+}
+
+async function scanGrantedDirectory(scanOptions) {
+ let core, report, grant;
+ try {
+ grant = currentApprovedGrant();
+ const root = verifyApprovedGrant(grant);
+ core = await loadGuard();
+ report = await core.scanPlugin(root, scanOptions);
+ verifyApprovedGrant(grant);
+ } catch (error) {
+ if (error?.code === 'WORKSPACE_APPROVAL_REQUIRED' || error?.code === 'APPROVED_DIRECTORY_UNAVAILABLE') throw error;
+ revokeGrant(grant);
+ throw approvedDirectoryUnavailable();
+ }
+ return { core, report };
+}
+
+async function scanApprovedForMcp(input) {
+ const options = validateToolInput(input);
+ const { core, report } = await scanGrantedDirectory({ findingLimit: MCP_FINDING_LIMIT, manifestFeatureLimit: MCP_FEATURE_LIMIT });
+ return buildMcpResponse(report, options, core);
+}
+
+function createBridge(api) {
+ const bridge = {
+ choosePluginDirectory: async () => {
+ if (typeof api.showOpenDialog !== 'function') throw new Error('当前 ZTools 环境不提供目录选择对话框。');
+ expireSession();
+ const epoch = sessionEpoch;
+ const result = selectedPath(await api.showOpenDialog({ title: '选择插件目录', properties: ['openDirectory'] }));
+ if (epoch !== sessionEpoch) throw sessionExpired();
+ if (!result) throw new Error('已取消选择。');
+ const grant = createDirectoryGrant(result);
+ if (epoch !== sessionEpoch) throw sessionExpired();
+ selectedRoot = grant;
+ return true;
+ },
+ scan: async () => {
+ const { report } = await scanGrantedDirectory();
+ const { root, ...safe } = report;
+ return safe;
+ },
+ copyText: typeof api.copyText === 'function' ? (text) => api.copyText(String(text)) : undefined
+ };
+ return Object.freeze(bridge);
+}
+
+function registerTools(target) {
+ const api = target?.ztools;
+ if (!api || typeof api.registerTool !== 'function' || registeredHosts.has(api)) return false;
+ let registered = false;
+ try { api.registerTool.call(api, TOOL_NAMES.scan, scanApprovedForMcp); registered = true; } catch {}
+ registeredHosts.add(api);
+ return registered;
+}
+
+function attachPluginGuard(target) {
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类 window 目标。');
+ const api = target.ztools || {};
+ target.pluginGuard = createBridge(api);
+ if (typeof api.onPluginOut === 'function') api.onPluginOut(expireSession);
+ registerTools(target);
+ return target.pluginGuard;
+}
+
+if (typeof globalThis !== 'undefined') attachPluginGuard(globalThis);
+
+module.exports = {
+ TOOL_NAMES,
+ GRANT_TTL_MS,
+ MCP_FINDING_LIMIT,
+ MCP_RESPONSE_BYTES,
+ validateToolInput,
+ paginateReport,
+ sanitizeOutput,
+ buildMcpResponse,
+ scanApprovedForMcp,
+ registerTools,
+ attachPluginGuard,
+ __testGrant: (root, expires = Date.now() + GRANT_TTL_MS) => { selectedRoot = createDirectoryGrant(root, expires); },
+ __testClear: expireSession
+};
diff --git a/plugins/plugin-guard/preload/package.json b/plugins/plugin-guard/preload/package.json
new file mode 100644
index 00000000..5bbefffb
--- /dev/null
+++ b/plugins/plugin-guard/preload/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "commonjs"
+}
diff --git a/plugins/plugin-guard/scripts/build.mjs b/plugins/plugin-guard/scripts/build.mjs
new file mode 100644
index 00000000..9add627b
--- /dev/null
+++ b/plugins/plugin-guard/scripts/build.mjs
@@ -0,0 +1 @@
+import { cp,mkdir,readFile,rm,writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root=path.resolve(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','ui'),dist,{recursive:true});await cp(path.join(root,'src','core'),path.join(dist,'core'),{recursive:true});await cp(path.join(root,'preload'),path.join(dist,'preload'),{recursive:true});await cp(path.join(root,'logo.svg'),path.join(dist,'logo.svg'));const app=await readFile(path.join(dist,'app.mjs'),'utf8');await writeFile(path.join(dist,'app.mjs'),app.replace("'../core/guard.mjs'", "'./core/guard.mjs'"));const m=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8'));delete m.development;m.main='index.html';m.logo='logo.svg';m.preload='preload/index.cjs';await writeFile(path.join(dist,'plugin.json'),JSON.stringify(m,null,2)+'\n');
diff --git a/plugins/plugin-guard/scripts/dist-size.mjs b/plugins/plugin-guard/scripts/dist-size.mjs
new file mode 100644
index 00000000..d249d628
--- /dev/null
+++ b/plugins/plugin-guard/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/plugin-guard/scripts/verify-dist.mjs b/plugins/plugin-guard/scripts/verify-dist.mjs
new file mode 100644
index 00000000..86f1b28c
--- /dev/null
+++ b/plugins/plugin-guard/scripts/verify-dist.mjs
@@ -0,0 +1,19 @@
+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.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const dist = path.join(root, 'dist');
+
+const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8'));
+if (manifest.development) throw new Error('development leaked');
+for (const file of [manifest.main, manifest.logo, manifest.preload, 'core/guard.mjs']) await access(path.join(dist, file));
+if (!manifest.tools?.scan_approved) throw new Error('missing MCP tool scan_approved');
+if (/node:|\.\.\/core\//.test(await readFile(path.join(dist, 'app.mjs'), 'utf8'))) throw new Error('renderer imports Node code');
+if (await readFile(path.join(root, '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', 'guard.mjs'), 'utf8') !== await readFile(path.join(dist, 'core', 'guard.mjs'), 'utf8')) throw new Error('dist guard core is stale');
+
+const distBytes = await directoryBytes(dist);
+assertDistSize(distBytes);
+console.log(`plugin-guard dist verified (${distBytes} bytes)`);
diff --git a/plugins/plugin-guard/src/core/guard.mjs b/plugins/plugin-guard/src/core/guard.mjs
new file mode 100644
index 00000000..2dcabd2e
--- /dev/null
+++ b/plugins/plugin-guard/src/core/guard.mjs
@@ -0,0 +1,358 @@
+import { lstat, open, readdir, realpath, stat } from 'node:fs/promises';
+import { constants as fsConstants } from 'node:fs';
+import path from 'node:path';
+const LIMITS = Object.freeze({ depth: 12, files: 1600, bytes: 24 * 1024 * 1024 });
+const DEFAULT_FINDING_LIMIT = 5000;
+const DEFAULT_IO = Object.freeze({ lstat, open, readdir, realpath, stat });
+const KNOWN_BRIDGE = new Set(['choosePluginDirectory','scan','copyText']);
+const FEATURE_CODES = /^[a-z0-9][a-z0-9-]{1,62}$/;
+const PEM_PRIVATE_KEY = /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gi;
+const SECRET_PATTERNS = Object.freeze([
+ /\bgithub_pat_[A-Za-z0-9_]*\b/gi,
+ /\bgh[pousr]_[A-Za-z0-9_]*\b/gi,
+ /\bsk-(?:proj-)?[A-Za-z0-9_-]{12,}\b/gi,
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
+ /\bAWS[_\s-]*SECRET[_\s-]*ACCESS[_\s-]*KEY\s*[:=]\s*['\"]?[A-Za-z0-9/+=._-]{8,}/gi,
+ /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/gi,
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
+ /\b(?:api[_\s-]?key|access[_\s-]?token|secret|token|password|signature)\s*[:=]\s*['\"]?[^\s,'\";]{6,}/gi
+]);
+const RISKY = [
+ ['dynamic-require', /require\s*\(\s*[^'"\s]/, 'high'], ['eval', /\beval\s*\(|\bFunction\s*\(/, 'high'],
+ ['child-process', /require\s*\(\s*['\"]child_process|\bexec(?:Sync|File)?\s*\(/, 'medium'], ['shell', /shell\s*:\s*true|exec\s*\(\s*[`'"]/, 'high'],
+ ['whole-module-bridge', /(?:globalThis|window)\.[\w$]+\s*=\s*(?:require\(|(?:fs|child_process)\b)/, 'high'], ['remote-url', /https?:\/\//i, 'low']
+];
+const BRIDGE_FIELD = /\bbridge\.([A-Za-z_$][\w$]*)\s*=/g;
+function issue(level, code, message, file) { return { level, code, message, ...(file ? { file } : {}) }; }
+function boundedOption(value, label) { if (value === undefined) return Infinity; if (!Number.isInteger(value) || value < 0 || value > 10000) throw new RangeError(`${label} 必须是 0 到 10,000 之间的整数。`); return value; }
+function branchContains(directoryRel, file) {
+ if (directoryRel === '') return true;
+ return typeof file === 'string' && (file === directoryRel || file.startsWith(`${directoryRel}/`));
+}
+function createFindingStore(limit) {
+ const store = { issues: [], risks: [], totals: { issues: 0, risks: 0 }, retained: 0 };
+ const counts = { issues: new Map(), risks: new Map() };
+ const sink = (kind) => Object.freeze({ push(value) {
+ const file = typeof value?.file === 'string' ? value.file : '';
+ counts[kind].set(file, (counts[kind].get(file) || 0) + 1);
+ store.totals[kind] += 1;
+ if (store.retained < limit) { store[kind].push(value); store.retained += 1; }
+ return store.totals[kind];
+ } });
+ const dropBranch = (directoryRel) => {
+ for (const kind of ['issues', 'risks']) {
+ let removedTotal = 0;
+ for (const [file, count] of counts[kind]) {
+ if (!branchContains(directoryRel, file)) continue;
+ removedTotal += count;
+ counts[kind].delete(file);
+ }
+ store.totals[kind] -= removedTotal;
+ const retainedBefore = store[kind].length;
+ store[kind] = store[kind].filter((finding) => !branchContains(directoryRel, finding?.file));
+ store.retained -= retainedBefore - store[kind].length;
+ }
+ };
+ return { store, issues: sink('issues'), risks: sink('risks'), dropBranch };
+}
+function within(root, candidate) { const rel = path.relative(root, candidate); return rel && !rel.startsWith('..'+path.sep) && rel !== '..' && !path.isAbsolute(rel); }
+function withinOrEqual(root, candidate) { return path.resolve(root) === path.resolve(candidate) || within(root, candidate); }
+function comparablePath(value) { const resolved=path.resolve(value); return process.platform==='win32'?resolved.toLocaleLowerCase('en-US'):resolved; }
+function sameCanonicalPath(left, right) { return comparablePath(left)===comparablePath(right); }
+function entrySnapshotMatches(left, right) { return Boolean(left&&right&&['dev','ino','mode','size','mtimeMs','ctimeMs'].every((key)=>Object.is(left[key],right[key]))); }
+function hasSnapshotValue(value, rejectZero = false) { return value!==undefined&&value!==null&&!(typeof value==='number'&&Number.isNaN(value))&&(!rejectZero||String(value)!=='0'); }
+function snapshotFieldsMatch(left, right, keys, requireOne = false) {
+ let compared=0;
+ for(const key of keys){
+ const hasLeft=hasSnapshotValue(left[key]),hasRight=hasSnapshotValue(right[key]);
+ if(hasLeft!==hasRight)return false;
+ if(!hasLeft)continue;
+ compared+=1;
+ if(!Object.is(left[key],right[key]))return false;
+ }
+ return !requireOne||compared>0;
+}
+function directorySnapshotMatches(left, right) {
+ if(!left||!right||!left.isDirectory()||!right.isDirectory())return false;
+ const stableId=hasSnapshotValue(left.ino,true)&&hasSnapshotValue(right.ino,true);
+ if(stableId&&!snapshotFieldsMatch(left,right,['dev','ino']))return false;
+ if(!snapshotFieldsMatch(left,right,['mode','mtimeMs','ctimeMs'],true))return false;
+ return stableId||snapshotFieldsMatch(left,right,['dev','birthtimeMs'],true);
+}
+function resolveIo(overrides) { const io={...DEFAULT_IO,...(overrides||{})}; for(const key of Object.keys(DEFAULT_IO))if(typeof io[key]!=='function')throw new TypeError(`io.${key} 必须是函数。`); return io; }
+export function manifestRelative(root, candidate, pathApi = path) { return pathApi.relative(root,candidate).split(pathApi.sep).join('/'); }
+export function safeRelative(value) { return typeof value === 'string' && value.length > 0 && !value.includes('\0') && !path.isAbsolute(value) && !/^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) && !value.split(/[\\/]+/).includes('..'); }
+export function maskSecrets(text) {
+ let output = String(text).replace(PEM_PRIVATE_KEY, '[redacted]');
+ for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, '[redacted]');
+ return output;
+}
+async function verifyDirectoryChain(root, directory, io) {
+ const relative=path.relative(root,directory);
+ if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('目录超出授权根目录。');
+ const segments=relative?relative.split(path.sep):[];
+ let current=root,last;
+ for(const segment of [null,...segments]){
+ if(segment!==null)current=path.join(current,segment);
+ const info=await io.lstat(current);
+ if(info.isSymbolicLink()||!info.isDirectory())throw new Error('目录链包含符号链接或非目录入口。');
+ last=info;
+ }
+ const canonical=await io.realpath(directory);
+ if(!withinOrEqual(root,canonical)||!sameCanonicalPath(canonical,directory))throw new Error('目录规范路径超出授权根目录。');
+ const after=await io.lstat(directory);
+ if(after.isSymbolicLink()||!directorySnapshotMatches(last,after))throw new Error('目录在验证过程中发生变化。');
+ return after;
+}
+async function inspectEntry(root, directory, full, expectedDirectory, io) {
+ const parentBefore=await verifyDirectoryChain(root,directory,io);
+ if(!directorySnapshotMatches(expectedDirectory,parentBefore))throw new Error('父目录在扫描过程中发生变化。');
+ const before=await io.lstat(full);
+ const parentAfter=await verifyDirectoryChain(root,directory,io);
+ const after=await io.lstat(full);
+ if(!directorySnapshotMatches(parentBefore,parentAfter)||!entrySnapshotMatches(before,after))throw new Error('目录入口在扫描过程中发生变化。');
+ return after;
+}
+async function verifyFileCandidate(root, full, io) {
+ if(!within(root,full))throw new Error('文件超出授权根目录。');
+ const directory=path.dirname(full),parentBefore=await verifyDirectoryChain(root,directory,io),before=await io.lstat(full);
+ if(before.isSymbolicLink()||!before.isFile())throw new Error('文件入口不是普通文件。');
+ const canonical=await io.realpath(full);
+ if(!within(root,canonical)||!sameCanonicalPath(canonical,full))throw new Error('文件规范路径超出授权根目录。');
+ const parentAfter=await verifyDirectoryChain(root,directory,io),after=await io.lstat(full);
+ if(!directorySnapshotMatches(parentBefore,parentAfter)||!fileSnapshotMatches(before,after))throw new Error('文件在规范路径验证过程中发生变化。');
+ return after;
+}
+function captureDirectorySnapshot(info) {
+ const isDirectory = info.isDirectory();
+ return Object.freeze({ dev:info.dev,ino:info.ino,birthtimeMs:info.birthtimeMs,ctimeMs:info.ctimeMs,mtimeMs:info.mtimeMs,mode:info.mode,isDirectory:()=>isDirectory });
+}
+function emptyBranch(issues = []) { return { entries:[],directories:[],files:0,bytes:0,issues,caseKeys:[] }; }
+function mergeBranch(target, branch, seenKeys) {
+ target.entries.push(...branch.entries); target.directories.push(...branch.directories); target.files+=branch.files; target.bytes+=branch.bytes; target.issues.push(...branch.issues); target.caseKeys.push(...branch.caseKeys);
+ for(const key of branch.caseKeys)seenKeys.add(key);
+}
+async function collectBranch(root, state, directory, depth, io, inheritedCaseKeys) {
+ const directoryRel=manifestRelative(root,directory);
+ if(depth>state.limits.depth)return emptyBranch([issue('high','depth-limit','目录深度超过上限。',directoryRel)]);
+ let directoryBefore;
+ try{directoryBefore=await verifyDirectoryChain(root,directory,io)}catch{return emptyBranch([issue('high','directory-race','目录在扫描过程中变得不安全。',directoryRel)])}
+ const branch=emptyBranch(),seenKeys=new Set(inheritedCaseKeys);
+ try{
+ const entries=await io.readdir(directory,{withFileTypes:true});
+ for(const entry of entries){
+ const full=path.join(directory,entry.name),rel=manifestRelative(root,full),info=await inspectEntry(root,directory,full,directoryBefore,io);
+ if(info.isSymbolicLink()){branch.issues.push(issue('high','symlink','不会扫描符号链接。',rel));continue}
+ if(info.isDirectory()){
+ const child=await collectBranch(root,state,full,depth+1,io,seenKeys);
+ mergeBranch(branch,child,seenKeys);
+ if(state.files+branch.files>state.limits.files||state.bytes+branch.bytes>state.limits.bytes)throw new RangeError('扫描超过安全上限。');
+ }else if(!info.isFile()){
+ branch.issues.push(issue('medium','special-file','已忽略特殊文件系统入口。',rel));
+ }else{
+ const safeInfo=await verifyFileCandidate(root,full,io);
+ if(!fileSnapshotMatches(info,safeInfo))throw new Error('候选文件在收集过程中发生变化。');
+ const nextFiles=branch.files+1,nextBytes=branch.bytes+safeInfo.size;
+ if(state.files+nextFiles>state.limits.files||state.bytes+nextBytes>state.limits.bytes)throw new RangeError('扫描超过安全上限。');
+ const key=rel.normalize('NFC').toLocaleLowerCase('en-US');
+ seenKeys.add(key);branch.caseKeys.push(key);branch.files=nextFiles;branch.bytes=nextBytes;branch.entries.push({rel,size:safeInfo.size});
+ }
+ }
+ const directoryAfter=await verifyDirectoryChain(root,directory,io);
+ if(!directorySnapshotMatches(directoryBefore,directoryAfter))throw new Error('目录在扫描过程中发生变化。');
+ branch.directories.push({rel:directoryRel,snapshot:captureDirectorySnapshot(directoryAfter)});
+ return branch;
+ }catch(error){
+ if(error instanceof RangeError)throw error;
+ return emptyBranch([issue('high','directory-race','目录在扫描过程中发生变化,已丢弃该目录的全部结果。',directoryRel)]);
+ }
+}
+async function collect(root, state, io) {
+ const branch=await collectBranch(root,state,root,0,io,state.caseKeys);
+ state.entries.push(...branch.entries);state.directories.push(...branch.directories);state.files=branch.files;state.bytes=branch.bytes;
+ for(const key of branch.caseKeys)state.caseKeys.add(key);
+ for(const finding of branch.issues)state.issues.push(finding);
+}
+function branchDirectory(root, relative) { return relative?path.join(root,...relative.split('/')):root; }
+function directoryIsAncestorOfFile(directoryRel, fileRel) {
+ if(directoryRel==='')return true;
+ const parent=path.posix.dirname(fileRel);
+ return parent===directoryRel||parent.startsWith(`${directoryRel}/`);
+}
+function invalidateCollectedBranch(state, findings, directoryRel) {
+ if(!state.directories.some((record)=>record.rel===directoryRel))return;
+ state.entries=state.entries.filter((entry)=>!branchContains(directoryRel,entry.rel));
+ state.directories=state.directories.filter((record)=>!branchContains(directoryRel,record.rel));
+ for(const rel of state.collectedSizes.keys())if(branchContains(directoryRel,rel))state.collectedSizes.delete(rel);
+ state.files=state.entries.length;
+ state.bytes=state.entries.reduce((total,entry)=>total+entry.size,0);
+ findings.dropBranch(directoryRel);
+ state.issues.push(issue('high','directory-race','目录在扫描后发生变化,已丢弃该目录的全部结果。',directoryRel));
+}
+async function verifyCollectedDirectories(root, state, io, findings, fileRel) {
+ const records=state.directories
+ .filter((record)=>fileRel===undefined||directoryIsAncestorOfFile(record.rel,fileRel))
+ .slice()
+ .sort((left,right)=>left.rel.split('/').filter(Boolean).length-right.rel.split('/').filter(Boolean).length);
+ for(const record of records){
+ if(!state.directories.includes(record))continue;
+ try{
+ const current=await verifyDirectoryChain(root,branchDirectory(root,record.rel),io);
+ if(!directorySnapshotMatches(record.snapshot,current))throw new Error('目录身份已变化。');
+ }catch{invalidateCollectedBranch(state,findings,record.rel)}
+ }
+}
+export function fileSnapshotMatches(left, right) { return Boolean(left&&right&&left.isFile()&&right.isFile()&&['dev','ino','size','mtimeMs','ctimeMs'].every((key)=>Object.is(left[key],right[key]))); }
+export async function readHandleBounded(handle, limit) { if(!Number.isSafeInteger(limit)||limit<0||limit>LIMITS.bytes)throw new RangeError('受审计文件超过扫描字节上限。');const buffer=Buffer.allocUnsafe(limit+1);let offset=0;while(offsetlimit)throw new RangeError('受审计文件增长后超过已授权大小。');return buffer.subarray(0,offset); }
+async function readAuditedFileBounded(root, full, expectedSize, io, verifyReadBoundary = async()=>{}) {
+ const before=await verifyFileCandidate(root,full,io);
+ if(before.size!==expectedSize)throw new Error('文件大小与有界扫描结果不一致。');
+ const flags=fsConstants.O_RDONLY|(process.platform==='win32'?0:(fsConstants.O_NOFOLLOW||0)),handle=await io.open(full,flags);
+ try{
+ const held=await handle.stat();
+ if(!fileSnapshotMatches(before,held))throw new Error('文件在打开过程中发生变化。');
+ const openedPath=await verifyFileCandidate(root,full,io);
+ if(!fileSnapshotMatches(held,openedPath))throw new Error('已打开文件不再位于授权根目录。');
+ await verifyReadBoundary();
+ const bytes=await readHandleBounded(handle,expectedSize),heldAfter=await handle.stat(),after=await verifyFileCandidate(root,full,io);
+ if(!fileSnapshotMatches(held,heldAfter)||!fileSnapshotMatches(heldAfter,after))throw new Error('文件在读取过程中发生变化。');
+ await verifyReadBoundary();
+ return bytes.toString('utf8');
+ }finally{await handle.close()}
+}
+function validateManifest(manifest, entries, issues) {
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return issues.push(issue('high','manifest','plugin.json 必须是对象。'));
+ for (const key of ['name','version','author','logo']) if (typeof manifest[key] !== 'string' || !manifest[key].trim()) issues.push(issue('high','manifest-field',`缺少 ${key} 或其值无效。`));
+ if (!manifest.main && !manifest.preload) issues.push(issue('high','entrypoint','清单需要 main 或 preload 入口。'));
+ for (const key of ['main','logo','preload']) if (manifest[key] && (!safeRelative(manifest[key]) || !entries.has(manifest[key]))) issues.push(issue('high','entrypoint',`${key} 不安全或缺失:${String(manifest[key])}。`));
+ const allowedCategories = new Set(['productivity','development','system','media','text','network','game','other']); if (!Array.isArray(manifest.categories) || manifest.categories.some((item)=>!allowedCategories.has(item))) issues.push(issue('medium','categories','categories 包含未知或无法跨平台使用的键。'));
+ if (!Array.isArray(manifest.platform) || !['darwin','win32','linux'].every((p)=>manifest.platform.includes(p))) issues.push(issue('medium','platform','清单应明确覆盖 darwin、win32 和 linux。'));
+ if (!Array.isArray(manifest.features)) { issues.push(issue('high','feature-list','features 必须是数组。')); return; }
+ const seen = new Set(); for (const feature of manifest.features) { if (!feature || !FEATURE_CODES.test(feature.code || '') || seen.has(feature.code)) issues.push(issue('high','feature-code','Feature code 必须唯一且范围明确。')); seen.add(feature?.code); if (!Array.isArray(feature?.cmds) || feature.cmds.some((x)=>{if(typeof x==='string')return !x.trim();if(!x||!['files','img','over'].includes(x.type)||!x.label)return true;if(x.type==='files')return !Number.isInteger(x.maxLength)||x.maxLength<1||(!Array.isArray(x.extensions)&&typeof x.match!=='string');if(x.type==='over')return !Number.isInteger(x.minLength)||!Number.isInteger(x.maxLength)||x.minLength<0||x.maxLength{
+ const rel=manifestRelative(root,full);
+ await verifyCollectedDirectories(root,state,io,findings,rel);
+ if(!state.collectedSizes.has(rel))throw new Error('文件不在有界扫描范围内。');
+ const verifyReadBoundary=async()=>{
+ const scope=needsInitialReadBarrier?undefined:rel;
+ needsInitialReadBarrier=false;
+ await verifyCollectedDirectories(root,state,io,findings,scope);
+ if(!state.collectedSizes.has(rel))throw new Error('文件所属目录在读取前发生变化。');
+ };
+ try{return await readAuditedFileBounded(root,full,state.collectedSizes.get(rel),io,verifyReadBoundary)}catch(error){invalidEntries.add(rel);throw error}
+ };
+ let index=new Set(state.entries.map((entry)=>entry.rel)),manifest;
+ if(!index.has('plugin.json')){
+ state.issues.push(issue('high','manifest','缺少 plugin.json。'));
+ }else{
+ try{
+ const source=await readAuditedFile(path.join(root,'plugin.json'));
+ try{manifest=JSON.parse(source)}catch{state.issues.push(issue('high','manifest-json','plugin.json 不是有效的 JSON。'))}
+ }catch{state.issues.push(issue('high','manifest-read','无法从授权根目录安全读取 plugin.json。'))}
+ }
+ const risks=findings.risks;
+ for(const entry of state.entries.filter((value)=>/\.(?:cjs|mjs|js|json|html)$/i.test(value.rel))){
+ if(!state.collectedSizes.has(entry.rel))continue;
+ const full=path.join(root,entry.rel);
+ if(!within(root,full)){state.issues.push(issue('high','containment','入口超出插件根目录。',entry.rel));invalidEntries.add(entry.rel);continue}
+ let source;
+ try{source=await readAuditedFile(full)}catch{if(state.collectedSizes.has(entry.rel))risks.push(issue('high','incomplete-file-read','无法安全、完整地读取文件。',entry.rel));continue}
+ for(const [code,re,level] of RISKY){re.lastIndex=0;if(re.test(source))risks.push(issue(level,code,`检测到静态模式:${code}。`,entry.rel))}
+ if(/preload/i.test(entry.rel)&&source.split(/\r?\n/).some((line)=>line.length>2000))risks.push(issue('medium','minified-preload','难以阅读或已打包的 preload 代码会扩大审计风险。',entry.rel));
+ const req=/require\s*\(\s*['\"](\.[^'\"]+)['\"]\s*\)/g;let required;
+ while((required=req.exec(source))){
+ const base=path.resolve(path.dirname(full),required[1]),candidates=[base,`${base}.js`,`${base}.cjs`,`${base}.json`,path.join(base,'index.js'),path.join(base,'index.cjs'),path.join(base,'index.json')];
+ if(!candidates.some((target)=>within(root,target))){risks.push(issue('high','relative-require-escape','相对 require 超出插件根目录。',entry.rel));continue}
+ let found=false;
+ for(const target of candidates.filter((candidate)=>within(root,candidate))){try{const targetRel=manifestRelative(root,target);await verifyCollectedDirectories(root,state,io,findings,targetRel);if(!state.collectedSizes.has(targetRel))continue;await verifyFileCandidate(root,target,io);await verifyCollectedDirectories(root,state,io,findings,targetRel);if(!state.collectedSizes.has(targetRel))continue;found=true;break}catch{}}
+ if(!found)risks.push(issue('high','missing-relative-require',`无法读取相对 require:${required[1]}。`,entry.rel));
+ }
+ BRIDGE_FIELD.lastIndex=0;let field;
+ while((field=BRIDGE_FIELD.exec(source)))if(!KNOWN_BRIDGE.has(field[1]))risks.push(issue('high','unknown-bridge',`未知桥接字段 ${field[1]} 已按失败关闭处理。`,entry.rel));
+ if(maskSecrets(source)!==source)risks.push(issue('high','secret','发现疑似凭据,报告中的值已脱敏。',entry.rel));
+ }
+ await verifyCollectedDirectories(root,state,io,findings);
+ let safeEntries=[];
+ for(const entry of [...state.entries]){
+ await verifyCollectedDirectories(root,state,io,findings,entry.rel);
+ if(!state.collectedSizes.has(entry.rel))continue;
+ const full=path.join(root,entry.rel);
+ try{const current=await verifyFileCandidate(root,full,io);if(current.size!==entry.size)throw new Error('文件大小发生变化。');await verifyCollectedDirectories(root,state,io,findings,entry.rel);if(state.collectedSizes.has(entry.rel))safeEntries.push(entry)}
+ catch{if(!invalidEntries.has(entry.rel))state.issues.push(issue('high','entry-invalidated','入口不再安全,已从扫描结果中移除。',entry.rel));invalidEntries.add(entry.rel)}
+ }
+ await verifyCollectedDirectories(root,state,io,findings);
+ safeEntries=safeEntries.filter((entry)=>state.collectedSizes.has(entry.rel));
+ index=new Set(safeEntries.map((entry)=>entry.rel));
+ if(manifest&&index.has('plugin.json'))validateManifest(manifest,index,state.issues);
+ else if(manifest&&!index.has('plugin.json'))manifest=undefined;
+ const collisionKeys=new Set();
+ for(const entry of safeEntries){const key=entry.rel.normalize('NFC').toLocaleLowerCase('en-US');if(collisionKeys.has(key))state.issues.push(issue('high','case-collision','大小写或 Unicode 冲突路径无法安全跨平台使用。',entry.rel));collisionKeys.add(key)}
+ const featureValues=Array.isArray(manifest?.features)?manifest.features:[];
+ const retainedFeatures=manifestFeatureLimit===Infinity?featureValues:featureValues.slice(0,manifestFeatureLimit);
+ const clean=(finding)=>({ ...finding, ...(finding.file?{file:maskSecrets(finding.file)}:{}), message:maskSecrets(finding.message) });
+ return {
+ root,
+ manifest:manifest?{name:maskSecrets(manifest.name),version:maskSecrets(manifest.version),features:retainedFeatures.map((feature)=>({code:maskSecrets(feature?.code||'')}))}:null,
+ manifestFeatureTotal:featureValues.length,
+ files:safeEntries.length,
+ bytes:safeEntries.reduce((total,entry)=>total+entry.size,0),
+ issues:findings.store.issues.map(clean),
+ risks:findings.store.risks.map(clean),
+ findingTotals:{issues:findings.store.totals.issues,risks:findings.store.totals.risks,findings:findings.store.totals.issues+findings.store.totals.risks},
+ retainedFindings:findings.store.retained,
+ findingsTruncated:findings.store.retained({ ...entry, rel:maskSecrets(entry.rel) })),
+ scannedAt:new Date().toISOString()
+ };
+}
+function markdownText(value) { return maskSecrets(value).replace(/[\r\n\t]+/g,' ').replace(/\\/g,'\\\\').replace(/([`*_[\]<>#])/g,'\\$1'); }
+function markdownCode(value) { return maskSecrets(value).replace(/[\r\n\t]+/g,' ').replace(/`/g,'ˋ'); }
+function humanText(value) { return String(value??'').replace(/\bFeature code\b/gi,'功能代码').replace(/\[redacted\]/gi,'[已脱敏]'); }
+const HUMAN_FINDING_CODES = Object.freeze({
+ 'dynamic-require': '动态依赖加载',
+ eval: '动态代码执行',
+ 'child-process': '子进程调用',
+ shell: '命令解释器执行',
+ 'whole-module-bridge': '整模块桥接暴露',
+ 'remote-url': '远程网址',
+ 'depth-limit': '目录深度超限',
+ 'directory-race': '目录竞态风险',
+ symlink: '符号链接',
+ 'special-file': '特殊文件',
+ 'case-collision': '路径大小写或 Unicode 冲突',
+ manifest: '插件清单',
+ 'manifest-field': '清单字段',
+ entrypoint: '插件入口',
+ categories: '分类配置',
+ platform: '平台范围',
+ 'feature-list': '功能列表',
+ 'feature-code': '功能代码',
+ 'feature-trigger': '功能触发器',
+ 'manifest-json': '清单格式',
+ 'manifest-read': '清单读取',
+ containment: '路径越界',
+ 'incomplete-file-read': '文件未完整读取',
+ 'minified-preload': '预加载脚本可读性',
+ 'relative-require-escape': '相对依赖越界',
+ 'missing-relative-require': '相对依赖缺失',
+ 'unknown-bridge': '未知桥接字段',
+ secret: '疑似凭据',
+ 'entry-invalidated': '入口已失效'
+});
+export function humanFindingCode(value) { return HUMAN_FINDING_CODES[String(value??'')] || '未分类检查项'; }
+function humanFindingMessage(value, code) { const text=humanText(value),raw=String(code??''),label=HUMAN_FINDING_CODES[raw]; return label?text.split(raw).join(label):text; }
+function humanLevel(value) { return ({high:'高',medium:'中',low:'低'})[value]||humanText(value); }
+export function toMarkdown(report) { const issues=Array.isArray(report.issues)?report.issues:[],risks=Array.isArray(report.risks)?report.risks:[],shown=Number.isFinite(Number(report.retainedFindings))?Number(report.retainedFindings):issues.length+risks.length,total=Number.isFinite(Number(report.findingTotals?.findings))?Number(report.findingTotals.findings):shown,truncated=Boolean(report.findingsTruncated)||shown 证据已截断;汇总数量覆盖完整扫描结果。'); rows.push('','## 发现'); for(const x of [...issues,...risks]) rows.push('- `'+markdownCode(humanLevel(x.level))+'` `'+markdownCode(humanFindingCode(x.code))+'`'+(x.file?' — `'+markdownCode(humanText(x.file))+'`':'')+':'+markdownText(humanFindingMessage(x.message,x.code))); return rows.join('\n'); }
+export { LIMITS, DEFAULT_FINDING_LIMIT, KNOWN_BRIDGE };
diff --git a/plugins/plugin-guard/src/ui/app.mjs b/plugins/plugin-guard/src/ui/app.mjs
new file mode 100644
index 00000000..e14517d5
--- /dev/null
+++ b/plugins/plugin-guard/src/ui/app.mjs
@@ -0,0 +1,15 @@
+const $=(id)=>document.getElementById(id);let last;const bridge=globalThis.pluginGuard;
+function markdownText(value){return String(value??'').replace(/[\r\n\t]+/g,' ').replace(/\\/g,'\\\\').replace(/([`*_[\]<>#])/g,'\\$1')}
+function markdownCode(value){return '`'+String(value??'').replace(/[\r\n\t]+/g,' ').replace(/`/g,'ˋ')+'`'}
+function humanText(value){return String(value??'').replace(/\bFeature code\b/gi,'功能代码').replace(/\[redacted\]/gi,'[已脱敏]')}
+const HUMAN_FINDING_CODES=Object.freeze({'dynamic-require':'动态依赖加载',eval:'动态代码执行','child-process':'子进程调用',shell:'命令解释器执行','whole-module-bridge':'整模块桥接暴露','remote-url':'远程网址','depth-limit':'目录深度超限','directory-race':'目录竞态风险',symlink:'符号链接','special-file':'特殊文件','case-collision':'路径大小写或 Unicode 冲突',manifest:'插件清单','manifest-field':'清单字段',entrypoint:'插件入口',categories:'分类配置',platform:'平台范围','feature-list':'功能列表','feature-code':'功能代码','feature-trigger':'功能触发器','manifest-json':'清单格式','manifest-read':'清单读取',containment:'路径越界','incomplete-file-read':'文件未完整读取','minified-preload':'预加载脚本可读性','relative-require-escape':'相对依赖越界','missing-relative-require':'相对依赖缺失','unknown-bridge':'未知桥接字段',secret:'疑似凭据','entry-invalidated':'入口已失效'});
+function humanFindingCode(value){return HUMAN_FINDING_CODES[String(value??'')]||'未分类检查项'}
+function humanFindingMessage(value,code){const text=humanText(value),raw=String(code??''),label=HUMAN_FINDING_CODES[raw];return label?text.split(raw).join(label):text}
+function humanLevel(value){return({high:'高',medium:'中',low:'低'})[value]||humanText(value)}
+function findingCounts(report){const shown=Number.isFinite(Number(report.retainedFindings))?Number(report.retainedFindings):(report.issues?.length||0)+(report.risks?.length||0),total=Number.isFinite(Number(report.findingTotals?.findings))?Number(report.findingTotals.findings):shown;return{shown,total,truncated:Boolean(report.findingsTruncated)||shown 证据已截断;汇总数量覆盖完整扫描结果。');rows.push('','## 发现');for(const x of [...report.issues,...report.risks])rows.push('- '+markdownCode(humanLevel(x.level))+' '+markdownCode(humanFindingCode(x.code))+(x.file?' — '+markdownCode(humanText(x.file)):'')+':'+markdownText(humanFindingMessage(x.message,x.code)));return rows.join('\n')}
+async function copy(text,label){try{if(bridge?.copyText)await bridge.copyText(text);else await navigator.clipboard.writeText(text);$('status').textContent=label}catch{$('status').textContent='当前环境无法复制'}}
+$('choose').addEventListener('click',async()=>{try{if(!bridge)throw new Error('插件安全体检桥接不可用。');await bridge.choosePluginDirectory();$('status').textContent='目录授权成功,尚未开始扫描。'}catch(e){$('status').textContent=e.message}});
+$('scan').addEventListener('click',async()=>{try{if(!bridge)throw new Error('插件安全体检桥接不可用。');$('status').textContent='正在进行只读扫描…';last=await bridge.scan();$('map').textContent=last.manifest?`${humanText(last.manifest.name)} · 功能代码:${last.manifest.features.map(f=>humanText(f.code)).join(', ')||'无'}`:'没有有效清单';$('inventory').textContent=`${last.files} 个文件 · ${last.bytes.toLocaleString()} 字节`; $('risks').replaceChildren();for(const finding of [...last.issues,...last.risks]){const li=document.createElement('li');li.className=finding.level;li.textContent=`${humanLevel(finding.level)} ${humanFindingCode(finding.code)}${finding.file?' · '+humanText(finding.file):''}:${humanFindingMessage(finding.message,finding.code)}`;$('risks').append(li)}const counts=findingCounts(last);$('status').textContent=`只读扫描完成 · 已展示 ${counts.shown.toLocaleString()} / ${counts.total.toLocaleString()} 项发现${counts.truncated?'(证据已截断)':''}`}catch(e){$('status').textContent=humanText(e.message)}});
+$('copyMarkdown').addEventListener('click',()=>last&©(markdown(last),'已复制脱敏后的 Markdown 报告'));
+$('copyJson').addEventListener('click',()=>last&©(JSON.stringify(last,null,2),'已复制脱敏后的 JSON 报告'));
diff --git a/plugins/plugin-guard/src/ui/index.html b/plugins/plugin-guard/src/ui/index.html
new file mode 100644
index 00000000..507428d8
--- /dev/null
+++ b/plugins/plugin-guard/src/ui/index.html
@@ -0,0 +1 @@
+插件安全体检
diff --git a/plugins/plugin-guard/src/ui/style.css b/plugins/plugin-guard/src/ui/style.css
new file mode 100644
index 00000000..518463e5
--- /dev/null
+++ b/plugins/plugin-guard/src/ui/style.css
@@ -0,0 +1 @@
+:root{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#eef3f3;background:#181c20}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#181c20,#25343b)}main{max-width:1120px;margin:auto;padding:32px 20px}header,.picker{display:flex;justify-content:space-between;gap:16px;align-items:center;flex-wrap:wrap}header p{color:#28b8bd;letter-spacing:.16em;font:700 11px ui-monospace,monospace;margin:0}h1{font-size:clamp(30px,5vw,52px);margin:3px 0}.panel{background:#242a2f;border:1px solid #475057;border-radius:14px;padding:18px;margin-top:18px}.grid{display:grid;grid-template-columns:1.5fr 1fr;gap:18px}.grid .panel{margin-top:18px}button,input{font:inherit}button{padding:9px 12px;border-radius:8px;border:1px solid #28b8bd;background:#1c5d62;color:white;font-weight:700;cursor:pointer;white-space:normal;overflow-wrap:anywhere;text-align:center}button:focus-visible,input:focus-visible{outline:3px solid #f1ad4e;outline-offset:3px}input{margin:10px 0;display:block;max-width:100%}.rail{border-top:5px solid #f1ad4e}#status,#map,#inventory,li{overflow-wrap:anywhere}.panel h2{text-wrap:pretty}ol{padding-left:22px}.high{border-left-color:#ec6660}.medium{border-left-color:#f1ad4e}.low{border-left-color:#28b8bd}li{border-left:4px solid;padding:8px;margin:8px 0;background:#1b2024;font:13px ui-monospace,monospace}@media(max-width:720px){main{padding:20px 12px}.grid{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
diff --git a/plugins/plugin-guard/test/branch-lifecycle.test.mjs b/plugins/plugin-guard/test/branch-lifecycle.test.mjs
new file mode 100644
index 00000000..fcaaff9d
--- /dev/null
+++ b/plugins/plugin-guard/test/branch-lifecycle.test.mjs
@@ -0,0 +1,101 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, open, lstat, readdir, realpath, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { scanPlugin } from '../src/core/guard.mjs';
+
+async function fixture(files) {
+ const root = await mkdtemp(path.join(tmpdir(), 'guard-branch-lifecycle-'));
+ for (const [relative, source] of Object.entries(files)) {
+ const target = path.join(root, relative);
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(target, source);
+ }
+ return root;
+}
+
+function validFiles(deepFiles) {
+ return {
+ 'plugin.json': JSON.stringify({ name: 'x', version: '1', author: 'a', main: 'index.html', logo: 'logo.svg', preload: 'preload.cjs', platform: ['darwin', 'win32', 'linux'], categories: ['other'], features: [] }),
+ 'index.html': 'ok',
+ 'logo.svg': 'x',
+ 'preload.cjs': 'bridge.scan = () => 1',
+ ...deepFiles
+ };
+}
+
+function changedDirectoryInfo(info) {
+ return new Proxy(info, { get(value, key) {
+ if (key === 'mtimeMs' || key === 'ctimeMs') return Number(value[key]) + 1000;
+ const item = Reflect.get(value, key, value);
+ return typeof item === 'function' ? item.bind(value) : item;
+ } });
+}
+
+test('drops a deep branch changed after collection before the first file read', async (t) => {
+ const input = await fixture(validFiles({ 'deep/inside.js': 'eval(1)' }));
+ t.after(() => rm(input, { recursive: true, force: true }));
+ const root = await realpath(input);
+ const deep = path.join(root, 'deep');
+ const late = path.join(deep, 'late.js');
+ const manifest = path.join(root, 'plugin.json');
+ let mutated = false;
+ let openedDeep = false;
+ const report = await scanPlugin(root, { io: {
+ lstat: async (target) => {
+ const info = await lstat(target);
+ return mutated && path.resolve(target) === path.resolve(deep) ? changedDirectoryInfo(info) : info;
+ },
+ open: async (target, flags) => {
+ if (!mutated && path.resolve(target) === path.resolve(manifest)) {
+ await writeFile(late, 'late');
+ mutated = true;
+ }
+ if (path.resolve(target).startsWith(`${path.resolve(deep)}${path.sep}`)) openedDeep = true;
+ return open(target, flags);
+ }
+ } });
+ assert.equal(mutated, true);
+ assert.equal(openedDeep, false);
+ assert.equal(report.issues.filter((finding) => finding.code === 'directory-race' && finding.file === 'deep').length, 1);
+ assert.equal(report.entries.some((entry) => entry.rel.startsWith('deep/')), false);
+ assert.equal(report.risks.some((finding) => finding.file?.startsWith('deep/')), false);
+ assert.equal(report.risks.some((finding) => finding.code === 'eval'), false);
+});
+
+test('invalidating a partially audited branch restores finding totals and does not read its open handle', async (t) => {
+ const input = await fixture(validFiles({ 'deep/a.js': 'eval(1); require(variable)', 'deep/b.js': 'safe' }));
+ t.after(() => rm(input, { recursive: true, force: true }));
+ const root = await realpath(input);
+ const deep = path.join(root, 'deep');
+ const late = path.join(deep, 'late.js');
+ const trigger = path.join(deep, 'b.js');
+ let mutated = false;
+ let readInvalidatedHandle = false;
+ const report = await scanPlugin(root, { findingLimit: 1, io: {
+ readdir: async (directory, options) => (await readdir(directory, options)).sort((left, right) => left.name.localeCompare(right.name)),
+ lstat: async (target) => {
+ const info = await lstat(target);
+ return mutated && path.resolve(target) === path.resolve(deep) ? changedDirectoryInfo(info) : info;
+ },
+ open: async (target, flags) => {
+ const handle = await open(target, flags);
+ if (path.resolve(target) !== path.resolve(trigger)) return handle;
+ await writeFile(late, 'late');
+ mutated = true;
+ return {
+ stat: (...args) => handle.stat(...args),
+ read: (...args) => { readInvalidatedHandle = true; return handle.read(...args); },
+ close: () => handle.close()
+ };
+ }
+ } });
+ assert.equal(mutated, true);
+ assert.equal(readInvalidatedHandle, false);
+ assert.deepEqual(report.findingTotals, { issues: 1, risks: 0, findings: 1 });
+ assert.equal(report.retainedFindings, 1);
+ assert.equal(report.findingsTruncated, false);
+ assert.equal(report.entries.some((entry) => entry.rel.startsWith('deep/')), false);
+ assert.equal([...report.issues, ...report.risks].some((finding) => finding.file?.startsWith('deep/') && finding.code !== 'directory-race'), false);
+});
diff --git a/plugins/plugin-guard/test/dist-size.test.mjs b/plugins/plugin-guard/test/dist-size.test.mjs
new file mode 100644
index 00000000..06f331c3
--- /dev/null
+++ b/plugins/plugin-guard/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/plugin-guard/test/guard.test.mjs b/plugins/plugin-guard/test/guard.test.mjs
new file mode 100644
index 00000000..12ff218f
--- /dev/null
+++ b/plugins/plugin-guard/test/guard.test.mjs
@@ -0,0 +1,42 @@
+import test from 'node:test';import assert from 'node:assert/strict';import {mkdtemp,writeFile,symlink,mkdir,rename,realpath as fsRealpath,readdir as fsReaddir,lstat as fsLstat,open as fsOpen}from 'node:fs/promises';import {tmpdir}from 'node:os';import path from 'node:path';import {fileSnapshotMatches,humanFindingCode,manifestRelative,readHandleBounded,scanPlugin,safeRelative,maskSecrets,toMarkdown}from '../src/core/guard.mjs';
+async function fixture(files={}){const root=await mkdtemp(path.join(tmpdir(),'guard-'));for(const [name,value] of Object.entries(files)){const p=path.join(root,name);await mkdir(path.dirname(p),{recursive:true});await writeFile(p,value)}return root}
+test('rejects unsafe entrypoint paths and secret exposure',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'../oops',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],features:[{code:'bad',cmds:['files']}]}),'logo.svg':'x','preload.cjs':"const key='sk-abcdefghijklmnopqrstuvwxyz'"});const r=await scanPlugin(root);assert.ok(r.issues.some(x=>x.code==='entrypoint'));assert.ok(r.risks.some(x=>x.code==='secret'));assert.ok(!JSON.stringify(r).includes('abcdefghijklmnopqrstuvwxyz'));});
+test('does not follow symlinks',async()=>{const root=await fixture({'plugin.json':'{}'});await symlink('/tmp',path.join(root,'out'));const r=await scanPlugin(root);assert.ok(r.issues.some(x=>x.code==='symlink'));});
+test('path policy is platform-independent',()=>{assert.equal(safeRelative('../x'),false);assert.equal(safeRelative('C:\\x'),false);assert.equal(safeRelative('src/main.js'),true);assert.match(maskSecrets('token=abcdefghijklmnop'),/redacted/);});
+test('win32 scanner paths use POSIX separators for manifest entrypoint comparison',()=>{const root='C:\\plugins\\plugin-guard',index=new Set(['src\\ui\\index.html','preload\\index.cjs','logo.svg'].map((relative)=>manifestRelative(root,path.win32.join(root,relative),path.win32)));assert.deepEqual([...index],['src/ui/index.html','preload/index.cjs','logo.svg']);assert.ok(index.has('src/ui/index.html'));assert.ok(index.has('preload/index.cjs'));});
+test('final sanitizer covers prefixed, labeled, and complete or truncated private-key credentials',()=>{const values=[`github_pat_${'A'.repeat(24)}`,`ghp_${'B'.repeat(36)}`,`AKIA${'C'.repeat(16)}`,`AWS_SECRET_ACCESS_KEY=${'D'.repeat(40)}`,`Bearer ${'E'.repeat(24)}`],complete=`-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----`,truncated=`-----BEGIN RSA PRIVATE KEY-----\n${'G'.repeat(64)}`;for(const value of [...values,complete,truncated]){const clean=maskSecrets(`before ${value} after`);assert.equal(clean.includes(value),false);assert.equal(clean.includes('BEGIN'),false);assert.match(clean,/redacted/);}});
+test('human Markdown localizes labels without changing stable report values',()=>{const finding={level:'high',code:'feature-code',file:'[redacted].js',message:'Feature code 包含 [redacted]。'},report={scannedAt:'now',files:1,bytes:1,issues:[finding],risks:[]},value=toMarkdown(report);assert.match(value,/`高` `功能代码`/);assert.match(value,/功能代码 包含/);assert.match(value,/已脱敏/);assert.doesNotMatch(value,/`high`|feature-code|Feature code|\[redacted\]/);assert.deepEqual(finding,{level:'high',code:'feature-code',file:'[redacted].js',message:'Feature code 包含 [redacted]。'});assert.equal(maskSecrets('token=abcdefghijklmnop'),'[redacted]');});
+test('every stable finding code has a deterministic Chinese human label',()=>{const expected={'dynamic-require':'动态依赖加载',eval:'动态代码执行','child-process':'子进程调用',shell:'命令解释器执行','whole-module-bridge':'整模块桥接暴露','remote-url':'远程网址','depth-limit':'目录深度超限','directory-race':'目录竞态风险',symlink:'符号链接','special-file':'特殊文件','case-collision':'路径大小写或 Unicode 冲突',manifest:'插件清单','manifest-field':'清单字段',entrypoint:'插件入口',categories:'分类配置',platform:'平台范围','feature-list':'功能列表','feature-code':'功能代码','feature-trigger':'功能触发器','manifest-json':'清单格式','manifest-read':'清单读取',containment:'路径越界','incomplete-file-read':'文件未完整读取','minified-preload':'预加载脚本可读性','relative-require-escape':'相对依赖越界','missing-relative-require':'相对依赖缺失','unknown-bridge':'未知桥接字段',secret:'疑似凭据','entry-invalidated':'入口已失效'};for(const [code,label] of Object.entries(expected))assert.equal(humanFindingCode(code),label);assert.equal(humanFindingCode('future-code'),'未分类检查项');});
+test('fails closed for unknown preload bridge fields',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.readEverything = () => 1'});const r=await scanPlugin(root);assert.ok(r.risks.some(x=>x.code==='unknown-bridge'));});
+test('only returns masked report fields and follows Node relative-require candidates',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'token=abcdefghijklmnop',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',categories:['other'],features:[{code:'safe'}]}), 'index.html':'ok','logo.svg':'x','preload.cjs':"require('./helper')",'helper.js':'module.exports=1','token=abcdefghijklmnop.js':'x'});const r=await scanPlugin(root);assert.ok(!JSON.stringify(r).includes('abcdefghijklmnop'));assert.equal(r.risks.some(x=>x.code==='missing-relative-require'),false);});
+test('scans full audited text beyond the former snippet boundary',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'x'.repeat(100000)+'\neval(1)'});const r=await scanPlugin(root);assert.ok(r.risks.some(x=>x.code==='eval'));});
+test('audited handle reads stop at the collected size plus one',async()=>{const fake=(value)=>{let cursor=0;return{async read(target,offset,length){const bytesRead=Math.min(length,value.length-cursor);if(bytesRead>0)value.copy(target,offset,cursor,cursor+bytesRead);cursor+=bytesRead;return{bytesRead}}}};assert.equal((await readHandleBounded(fake(Buffer.from('abc')),3)).toString(),'abc');await assert.rejects(readHandleBounded(fake(Buffer.from('abcd')),3),/增长后超过已授权大小/);});
+test('audited file snapshots reject same-size identity or timestamp changes',()=>{const snapshot=(values={})=>({isFile:()=>true,dev:1,ino:2,size:3,mtimeMs:4,ctimeMs:5,...values});assert.equal(fileSnapshotMatches(snapshot(),snapshot()),true);assert.equal(fileSnapshotMatches(snapshot(),snapshot({ino:9})),false);assert.equal(fileSnapshotMatches(snapshot(),snapshot({ctimeMs:9})),false);});
+test('drops a whole deep branch when its directory changes in place after readdir',async()=>{
+ const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}),'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/inside.js':'inside-safe'});
+ const canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),late=path.join(deep,'late.js');
+ let mutated=false,openedDeep=false;
+ const r=await scanPlugin(root,{io:{
+ readdir:async(directory,options)=>{
+ const entries=await fsReaddir(directory,options);
+ if(!mutated&&path.resolve(directory)===path.resolve(deep)){await writeFile(late,'eval(1)');mutated=true}
+ return entries;
+ },
+ lstat:async(target)=>{
+ const info=await fsLstat(target);
+ if(!mutated||path.resolve(target)!==path.resolve(deep))return info;
+ return new Proxy(info,{get(value,key){if(key==='mtimeMs'||key==='ctimeMs')return Number(value[key])+1000;const item=Reflect.get(value,key,value);return typeof item==='function'?item.bind(value):item}});
+ },
+ open:async(target,flags)=>{if(path.resolve(target).startsWith(`${path.resolve(deep)}${path.sep}`))openedDeep=true;return fsOpen(target,flags)}
+ }});
+ assert.equal(mutated,true);
+ assert.equal(openedDeep,false);
+ assert.ok(r.issues.some((finding)=>finding.code==='directory-race'&&finding.file==='deep'));
+ assert.equal(r.entries.some((entry)=>entry.rel==='deep/inside.js'||entry.rel==='deep/late.js'),false);
+ assert.equal(r.risks.some((finding)=>finding.file?.startsWith('deep/')),false);
+ assert.equal(JSON.stringify(r).includes('late.js'),false);
+});
+test('drops every external entry when a deep directory is replaced before readdir',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/inside.js':'inside'}),outside=await fixture({'outside-secret-marker.js':'eval(1)'}),canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),backup=path.join(canonicalRoot,'deep-original');let swapped=false;const r=await scanPlugin(root,{io:{readdir:async(directory,options)=>{if(!swapped&&path.resolve(directory)===path.resolve(deep)){swapped=true;await rename(deep,backup);await symlink(outside,deep,process.platform==='win32'?'junction':'dir')}return fsReaddir(directory,options)}}});assert.equal(swapped,true);assert.ok(r.issues.some((finding)=>finding.code==='directory-race'));assert.equal(r.entries.some((entry)=>entry.rel.includes('outside-secret-marker')),false);assert.equal(r.risks.some((finding)=>finding.file?.includes('outside-secret-marker')),false);assert.equal(JSON.stringify(r).includes('outside-secret-marker'),false);});
+test('does not read and does not retain a file opened through a replaced parent directory',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/target.js':'inside-safe'}),outside=await fixture({'target.js':'outside-secret-marker'}),canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),backup=path.join(canonicalRoot,'deep-original'),target=path.join(deep,'target.js');let swapped=false,readCalled=false;const r=await scanPlugin(root,{io:{open:async(file,flags)=>{if(!swapped&&path.resolve(file)===path.resolve(target)){swapped=true;await rename(deep,backup);await symlink(outside,deep,process.platform==='win32'?'junction':'dir')}const handle=await fsOpen(file,flags);if(path.resolve(file)!==path.resolve(target))return handle;return{stat:(...args)=>handle.stat(...args),read:(...args)=>{readCalled=true;return handle.read(...args)},close:()=>handle.close()}}}});assert.equal(swapped,true);assert.equal(readCalled,false);assert.ok(r.issues.some((finding)=>finding.code==='directory-race'));assert.equal(r.risks.some((finding)=>finding.file?.startsWith('deep/')),false);assert.equal(r.entries.some((entry)=>entry.rel==='deep/target.js'),false);assert.equal(JSON.stringify(r).includes('outside-secret-marker'),false);});
+test('markdown report escapes untrusted file names and finding text',()=>{const value=toMarkdown({scannedAt:'now',files:1,bytes:1,issues:[{level:'high',code:'x`\n## forged',file:'bad`\n- injected.md',message:'line\n# fake'}],risks:[]});assert.ok(!value.includes('\n## forged'));assert.ok(!value.includes('\n- injected.md'));assert.ok(!value.includes('\n# fake'));});
+test('default human scan retains 5,000 findings, preserves totals, and marks copied reports truncated',async()=>{const hits=5001,root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.unapproved = 1;\n'.repeat(hits)}),r=await scanPlugin(root);assert.equal(r.findingTotals.risks,hits);assert.equal(r.findingTotals.findings,hits);assert.equal(r.retainedFindings,5000);assert.equal(r.issues.length+r.risks.length,5000);assert.equal(r.findingsTruncated,true);const copied=toMarkdown(r);assert.match(copied,/已展示发现:5000 \/ 5001/);assert.match(copied,/证据已截断;汇总数量覆盖完整扫描结果。/);});
diff --git a/plugins/plugin-guard/test/manifest.test.mjs b/plugins/plugin-guard/test/manifest.test.mjs
new file mode 100644
index 00000000..3792c7bd
--- /dev/null
+++ b/plugins/plugin-guard/test/manifest.test.mjs
@@ -0,0 +1,30 @@
+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/ui/index.html');
+ assert.equal(manifest.preload, '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('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 m.development", "m.main='index.html'", "m.preload='preload/index.cjs'", "m.logo='logo.svg'"]) {
+ assert.ok(buildScript.includes(contract), `build script must preserve ${contract}`);
+ }
+});
diff --git a/plugins/plugin-guard/test/mcp.test.mjs b/plugins/plugin-guard/test/mcp.test.mjs
new file mode 100644
index 00000000..971d2deb
--- /dev/null
+++ b/plugins/plugin-guard/test/mcp.test.mjs
@@ -0,0 +1,255 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, readFile, rename, rm, 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 { maskSecrets, toMarkdown } from '../src/core/guard.mjs';
+
+const require = createRequire(import.meta.url);
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const preload = require('../preload/index.cjs');
+const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8'));
+
+async function fixture(preloadSource = 'eval(1)') {
+ const directory = await mkdtemp(path.join(tmpdir(), 'guard-mcp-'));
+ await mkdir(path.join(directory, 'src'));
+ await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({ name: 'safe', version: '1', author: 'a', main: 'index.html', preload: 'preload.cjs', logo: 'logo.svg', platform: ['darwin', 'win32', 'linux'], categories: ['other'], features: [] }));
+ await writeFile(path.join(directory, 'index.html'), 'ok');
+ await writeFile(path.join(directory, 'preload.cjs'), preloadSource);
+ await writeFile(path.join(directory, 'logo.svg'), '');
+ await writeFile(path.join(directory, 'src', 'token=abcdefghijklmnop.js'), 'ok');
+ return directory;
+}
+
+function highEscapingFindings(count = 400) {
+ return Array.from({ length: count }, (_, index) => `require('./item${String(index).padStart(3, '0')}-${'\\'.repeat(600)}')`).join('\n');
+}
+
+function pageMarkerIds(result, report) {
+ if (report === 'markdown') return [...result.markdown.matchAll(/item(\d{3})-/g)].map((match) => Number(match[1]));
+ return [...result.json.issues, ...result.json.risks].map((finding) => Number(finding.message.match(/item(\d{3})-/)?.[1]));
+}
+
+test('manifest declaration and native registration stay one-to-one', () => {
+ const handlers = new Map();
+ const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } };
+ preload.attachPluginGuard(target);
+ assert.deepEqual([...handlers.keys()], Object.keys(manifest.tools));
+ assert.equal(typeof handlers.get('scan_approved'), 'function');
+ assert.equal(manifest.tools.scan_approved.inputSchema.properties.offset.maximum, 400);
+});
+
+test('older hosts keep the UI bridge without registering tools', () => {
+ const target = { ztools: {} };
+ assert.doesNotThrow(() => preload.attachPluginGuard(target));
+ assert.equal(typeof target.pluginGuard.choosePluginDirectory, 'function');
+});
+
+test('registration failure does not block the human UI bridge', () => {
+ const target = { ztools: { registerTool() { throw Error('unsupported'); } } };
+ assert.doesNotThrow(() => preload.attachPluginGuard(target));
+ assert.equal(typeof target.pluginGuard.scan, 'function');
+});
+
+test('agent scan requires a live human UI grant and rejects paths', async () => {
+ preload.__testClear();
+ await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' });
+ await assert.rejects(preload.scanApprovedForMcp({ path: '/tmp' }), { code: 'INVALID_TOOL_INPUT' });
+ preload.__testGrant(await fixture(), 0);
+ await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' });
+});
+
+test('real handler strips root and entries, masks values, and preserves totals', async () => {
+ const directory = await fixture();
+ preload.__testGrant(directory);
+ const result = await preload.scanApprovedForMcp({ report: 'both', offset: 0, limit: 1 });
+ assert.equal(Object.hasOwn(result.json, 'root'), false);
+ assert.equal(Object.hasOwn(result.json, 'entries'), false);
+ assert.equal(result.pagination.limit, 1);
+ assert.ok(result.totals.findings >= 1);
+ assert.match(result.markdown, /插件安全体检报告/);
+ assert.doesNotMatch(JSON.stringify(result), new RegExp(directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ assert.doesNotMatch(JSON.stringify(result), /abcdefghijklmnop/);
+});
+
+test('registered handler rejects a renamed-and-replaced approved directory before scanning it', async () => {
+ const handlers = new Map();
+ preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } });
+ const directory = await fixture();
+ const moved = `${directory}.old`;
+ preload.__testGrant(directory);
+ await rename(directory, moved);
+ await mkdir(directory);
+ await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({ name: 'replacement-must-not-scan' }));
+ await assert.rejects(handlers.get('scan_approved')({}), (error) => {
+ assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE');
+ assert.equal(error.message, '已授权的插件目录不可用,请在界面中重新选择。');
+ assert.equal(error.message.includes(directory), false);
+ assert.equal(error.message.includes(moved), false);
+ assert.equal(error.message.includes('replacement-must-not-scan'), false);
+ return true;
+ });
+ await assert.rejects(handlers.get('scan_approved')({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' });
+});
+
+test('human bridge applies the same directory-identity check before scanning', async () => {
+ const directory = await fixture();
+ const moved = `${directory}.old`;
+ const target = { ztools: { showOpenDialog: async () => ({ filePaths: [directory] }) } };
+ preload.attachPluginGuard(target);
+ await target.pluginGuard.choosePluginDirectory();
+ await rename(directory, moved);
+ await mkdir(directory);
+ await assert.rejects(target.pluginGuard.scan(), (error) => {
+ assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE');
+ assert.equal(error.message.includes(directory), false);
+ return true;
+ });
+});
+
+test('plugin out expires a pending directory chooser and cannot restore MCP authorization', async () => {
+ const directory = await fixture();
+ const handlers = new Map();
+ let resolveDialog;
+ let onPluginOut;
+ const target = { ztools: {
+ showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }),
+ onPluginOut(listener) { onPluginOut = listener; },
+ registerTool(name, handler) { handlers.set(name, handler); }
+ } };
+ preload.attachPluginGuard(target);
+ const choosing = target.pluginGuard.choosePluginDirectory();
+ onPluginOut();
+ resolveDialog({ filePaths: [directory] });
+ await assert.rejects(choosing, { code: 'SESSION_EXPIRED' });
+ await assert.rejects(handlers.get('scan_approved')({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' });
+});
+
+test('registered handler sanitizes every credential-bearing manifest, message, and file field', async () => {
+ const handlers = new Map();
+ preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } });
+ const github = `github_pat_${'A'.repeat(32)}`;
+ const ghp = `ghp_${'B'.repeat(36)}`;
+ const awsAccess = `AKIA${'C'.repeat(16)}`;
+ const awsSecret = 'D'.repeat(40);
+ const pemBody = 'E'.repeat(72);
+ const truncatedPem = `-----BEGIN PRIVATE KEY-----\n${pemBody}`;
+ const directory = await fixture();
+ await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({
+ name: `${github} ${truncatedPem}`,
+ description: `Bearer ${'F'.repeat(24)}`,
+ version: `AWS_SECRET_ACCESS_KEY=${awsSecret}`,
+ author: 'a',
+ main: `${github}.html`,
+ preload: 'preload.cjs',
+ logo: 'logo.svg',
+ platform: ['darwin', 'win32', 'linux'],
+ categories: ['other'],
+ features: [{ code: awsAccess, explain: truncatedPem, cmds: ['scan'] }]
+ }));
+ await writeFile(path.join(directory, `${ghp}.js`), `eval(1); const key = '${awsAccess}';`);
+ preload.__testGrant(directory);
+ const result = await handlers.get('scan_approved')({ report: 'both', limit: 200 });
+ const serialized = JSON.stringify(result);
+ for (const secret of [github, ghp, awsAccess, awsSecret, pemBody]) assert.equal(serialized.includes(secret), false);
+ assert.equal(serialized.includes('BEGIN PRIVATE KEY'), false);
+ assert.equal(serialized.includes('AWS_SECRET_ACCESS_KEY'), false);
+ assert.match(serialized, /redacted/i);
+});
+
+test('50,000 findings keep full totals while retaining and returning bounded evidence', async () => {
+ const hitCount = 50001;
+ const directory = await fixture('bridge.unapproved = 1;\n'.repeat(hitCount));
+ preload.__testGrant(directory);
+ const result = await preload.scanApprovedForMcp({ report: 'json', offset: 200, limit: 200 });
+ assert.equal(result.totals.risks, hitCount);
+ assert.equal(result.totals.findings, hitCount);
+ assert.ok(result.totals.retainedFindings <= 400);
+ assert.ok(result.json.issues.length + result.json.risks.length <= 200);
+ assert.ok(result.pagination.returned <= 200);
+ assert.equal(result.pagination.nextOffset, 400);
+ assert.equal(result.pagination.hasMore, false);
+});
+
+test('registered handler paginates every retained finding across json, markdown, and both under budget', async () => {
+ const handlers = new Map();
+ preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } });
+ const directory = await fixture(highEscapingFindings());
+ preload.__testGrant(directory);
+ const handler = handlers.get('scan_approved');
+ const sawBudgetReduction = Object.create(null);
+ for (const report of ['json', 'markdown', 'both']) {
+ const ids = [];
+ let offset = 0;
+ while (true) {
+ const result = await handler({ report, offset, limit: 200 });
+ assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES);
+ assert.equal(result.totals.findings, 400);
+ assert.equal(result.totals.risks, 400);
+ assert.equal(result.totals.retainedFindings, 400);
+ assert.equal(result.pagination.offset, offset);
+ assert.equal(result.pagination.nextOffset, offset + result.pagination.returned);
+ assert.equal(result.responseTruncated, result.pagination.returned < Math.min(200, Math.max(0, 400 - offset)));
+ sawBudgetReduction[report] ||= result.responseTruncated;
+ const pageIds = pageMarkerIds(result, report);
+ assert.equal(pageIds.length, result.pagination.returned);
+ assert.equal(pageIds.every(Number.isInteger), true);
+ ids.push(...pageIds);
+ if (!result.pagination.hasMore) {
+ assert.equal(result.pagination.nextOffset, 400);
+ break;
+ }
+ assert.ok(result.pagination.returned > 0);
+ offset = result.pagination.nextOffset;
+ }
+ assert.equal(ids.length, 400);
+ assert.equal(new Set(ids).size, 400);
+ assert.deepEqual([...ids].sort((left, right) => left - right), Array.from({ length: 400 }, (_, index) => index));
+ const terminal = await handler({ report, offset: 400, limit: 200 });
+ assert.ok(Buffer.byteLength(JSON.stringify(terminal), 'utf8') <= preload.MCP_RESPONSE_BYTES);
+ assert.equal(terminal.pagination.returned, 0);
+ assert.equal(terminal.pagination.nextOffset, 400);
+ assert.equal(terminal.pagination.hasMore, false);
+ assert.equal(terminal.responseTruncated, false);
+ assert.equal(terminal.totals.findings, 400);
+ }
+ assert.equal(sawBudgetReduction.both, true);
+});
+
+test('maximum report=both page stays under the serialized response budget', () => {
+ const finding = { level: 'high', code: 'bounded', message: '密'.repeat(1000), file: '\\'.repeat(2000) };
+ const report = { manifest: { name: 'safe', version: '1', features: [] }, manifestFeatureTotal: 0, files: 1, bytes: 1, scannedAt: 'now', issues: Array.from({ length: 400 }, () => finding), risks: [], findingTotals: { issues: 400, risks: 0, findings: 400 } };
+ const result = preload.buildMcpResponse(report, { report: 'both', offset: 0, limit: 200 }, { maskSecrets, toMarkdown });
+ assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES);
+ assert.equal(result.totals.findings, 400);
+ assert.equal(result.json.totals.findings, 400);
+ assert.equal(result.responseTruncated, true);
+ assert.equal(result.pagination.responseTruncated, true);
+ assert.equal(result.json.pagination.responseTruncated, true);
+ assert.equal(result.pagination.hasMore, true);
+ assert.ok(result.pagination.returned < result.pagination.requestedLimit);
+});
+
+test('deleted approved directory revokes grant and returns a stable path-free error', async () => {
+ const directory = await fixture();
+ preload.__testGrant(directory);
+ await rm(directory, { recursive: true, force: true });
+ await assert.rejects(preload.scanApprovedForMcp({}), (error) => {
+ assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE');
+ assert.equal(error.message, '已授权的插件目录不可用,请在界面中重新选择。');
+ assert.equal(error.message.includes(directory), false);
+ return true;
+ });
+ await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' });
+});
+
+test('strict validation bounds report pagination and hostile keys', async () => {
+ await assert.rejects(preload.scanApprovedForMcp({ limit: 201 }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.scanApprovedForMcp({ offset: -1 }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.scanApprovedForMcp({ offset: 401 }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.scanApprovedForMcp({ report: 'html' }), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.scanApprovedForMcp(JSON.parse('{"__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' });
+ await assert.rejects(preload.scanApprovedForMcp(Object.create({ report: 'json' })), { code: 'INVALID_TOOL_INPUT' });
+});
diff --git a/plugins/plugin-guard/test/renderer-bridge.test.mjs b/plugins/plugin-guard/test/renderer-bridge.test.mjs
new file mode 100644
index 00000000..570a397d
--- /dev/null
+++ b/plugins/plugin-guard/test/renderer-bridge.test.mjs
@@ -0,0 +1,3 @@
+import test from 'node:test';import assert from 'node:assert/strict';import {readFile} from 'node:fs/promises';import path from 'node:path';import {fileURLToPath} from 'node:url';
+const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
+test('renderer has no Node core import, localizes human findings, and bridge remains narrow',async()=>{const ui=await readFile(path.join(root,'src','ui','app.mjs'),'utf8'),preload=await readFile(path.join(root,'preload','index.cjs'),'utf8'),html=await readFile(path.join(root,'src','ui','index.html'),'utf8'),logo=await readFile(path.join(root,'logo.svg'),'utf8');assert.ok(!/node:|\.\.\/core\//.test(ui));assert.ok(!ui.includes('innerHTML'));assert.match(ui,/function humanText/);assert.match(ui,/high:'高',medium:'中',low:'低'/);assert.match(ui,/symlink:'符号链接'/);assert.match(ui,/'manifest-field':'清单字段'/);assert.match(ui,/'feature-trigger':'功能触发器'/);assert.match(ui,/humanFindingCode\(finding\.code\)/);assert.match(ui,/功能代码:\$\{last\.manifest\.features/);assert.match(ui,/JSON\.stringify\(last,null,2\)/);assert.match(ui,/已展示 \$\{counts\.shown\.toLocaleString\(\)\} \/ \$\{counts\.total\.toLocaleString\(\)\} 项发现/);assert.match(ui,/证据已截断;汇总数量覆盖完整扫描结果。/);assert.match(html,/lang="zh-CN"/);assert.match(logo,/aria-label="插件安全体检"/);assert.doesNotMatch(logo,/aria-label="Plugin Guard"/);assert.match(preload,/Object\.freeze\(bridge\)/);assert.ok(!/require\(['\"]node:child_process/.test(preload));});