决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于 被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型 (用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。 MCP(41 → 42 个工具): - defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/ collect/verify + since/limit,additionalProperties: false) - index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2, 分发走 default 直通(参数名与被控端一致,无需改写) - mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、 新增「na_audit_l2 必须紧随 na_audit」顺序断言 控制台: - conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端 上按钮禁用并明示原因,不搞「点了才报错」) - types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐 manifest returns_schema)+ AuditType += agent.l2; AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段) - AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略 (NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明 (采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本) agent.l2 审计类型四端同源: - protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2 - agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底) - audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与 进程生命周期无关,混类会污染两边统计与筛选) 守卫加强(console-contract 平台门控): - audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows (错误信息格式统一 + 可被守卫扫到) - 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大; 否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红 - mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名, na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+ 新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema / 契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 / 界面纪律(信任边界文案 + enable 二次确认 + 平台门控) 故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿 单测 451/451、e2e 33/33、typecheck 全绿
236 lines
10 KiB
JavaScript
236 lines
10 KiB
JavaScript
/**
|
||
* 控制台契约守卫:`apps/console/src/types/index.ts` 里声明的字段名,
|
||
* **必须**能在协议 manifest 的 `returns_schema` 里找到。
|
||
*
|
||
* ⚠️ 背景(2026-10-07 实测才发现):M3 七个页面写完后,用 `examples/probe.rs`
|
||
* 对真实被控端跑了一遍字段契约探针,抓到 4 处**界面恒空**的漂移:
|
||
* - 进程表读 `r.cpu` / `r.memory`,真源是 `cpu_pct` / `memory_bytes` → CPU、内存两列恒为 "-"
|
||
* - 磁盘读 `d.mount || d.name` / `d.used`,真源是 `drive`,且**没有 used**(只有 total/free)→ 盘名与已用量恒空
|
||
* - 服务表读 `r.status`,真源是 `state` → 状态列恒空
|
||
* - 软件表读 `a.id`,真源是 `publisher`(`app.list` 结果**没有 id**)→ 整列恒空
|
||
* 这些都不是运行时异常,是**静默空列**——类型系统抓不到(TS 只是声明,没人跟契约比对),
|
||
* 页面上也只是显示 "-"。所以必须由本用例把「声明 ⊆ 契约」这条铁律钉死。
|
||
*
|
||
* 反向也查(契约 ⊆ 声明):只对 `returns_schema` **完整枚举**了属性的能力生效,
|
||
* 避免"契约加了字段、前端没跟上"这种漏检。
|
||
*
|
||
* 数据源一律取 `packages/protocol/dist`(已构建),与 capabilities.test.mjs 保持一致。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { findCapability } from '../../packages/protocol/dist/index.js';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const repoRoot = join(here, '..', '..');
|
||
const CONSOLE_TYPES = join(repoRoot, 'apps', 'console', 'src', 'types', 'index.ts');
|
||
const CONN_STORE = join(repoRoot, 'apps', 'console', 'src', 'stores', 'conn.ts');
|
||
|
||
const source = readFileSync(CONSOLE_TYPES, 'utf8');
|
||
|
||
/**
|
||
* 极简 TS interface 解析:只取**顶层**字段名(大括号深度 = 1),
|
||
* 忽略注释、字符串与嵌套对象字面量。够用于本项目这些扁平接口。
|
||
*/
|
||
function fieldsOf(interfaceName) {
|
||
const start = source.indexOf(`export interface ${interfaceName} {`);
|
||
if (start < 0) throw new Error(`未找到 interface ${interfaceName}`);
|
||
const bodyStart = source.indexOf('{', start);
|
||
let depth = 0;
|
||
let body = '';
|
||
for (let i = bodyStart; i < source.length; i += 1) {
|
||
const ch = source[i];
|
||
if (ch === '{') {
|
||
depth += 1;
|
||
if (depth === 1) continue;
|
||
}
|
||
if (ch === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) break;
|
||
}
|
||
body += ch;
|
||
}
|
||
|
||
const names = new Set();
|
||
let nest = 0;
|
||
for (const raw of body.split('\n')) {
|
||
const line = raw.replace(/\/\/.*$/, '').trim();
|
||
if (!line) continue;
|
||
const opens = (line.match(/\{/g) ?? []).length;
|
||
const closes = (line.match(/\}/g) ?? []).length;
|
||
// 只在最外层(nest === 0)收集 `key?: type` / `key: type`
|
||
if (nest === 0) {
|
||
const m = /^([A-Za-z_$][\w$]*)\s*\??\s*:/.exec(line);
|
||
if (m) names.add(m[1]);
|
||
// 索引签名 `[k: string]: unknown` 跳过
|
||
}
|
||
nest += opens - closes;
|
||
}
|
||
return names;
|
||
}
|
||
|
||
/** 取某能力 `returns_schema.properties` 的键集合。 */
|
||
function contractTop(capName) {
|
||
const cap = findCapability(capName);
|
||
assert.ok(cap, `能力 ${capName} 不存在于 manifest`);
|
||
return new Set(Object.keys(cap.returns_schema?.properties ?? {}));
|
||
}
|
||
|
||
/** 取某能力某个数组字段 `items.properties` 的键集合。 */
|
||
function contractItems(capName, arrayField) {
|
||
const cap = findCapability(capName);
|
||
const arr = cap?.returns_schema?.properties?.[arrayField];
|
||
assert.ok(arr, `${capName}.returns_schema.${arrayField} 不存在`);
|
||
return new Set(Object.keys(arr.items?.properties ?? {}));
|
||
}
|
||
|
||
/** 取某能力某对象字段(如 system.status.disks)的 items/自属性键集合。 */
|
||
function contractNested(capName, objField) {
|
||
const cap = findCapability(capName);
|
||
const node = cap?.returns_schema?.properties?.[objField];
|
||
assert.ok(node, `${capName}.returns_schema.${objField} 不存在`);
|
||
return new Set(Object.keys(node.items?.properties ?? node.properties ?? {}));
|
||
}
|
||
|
||
// ---------- 声明 ⊆ 契约(抓「声明了不存在的字段」) ----------
|
||
|
||
/** [ 控制台 interface, 能力名, 契约字段来源 ] */
|
||
const FORWARD_CASES = [
|
||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||
['DiskInfo', 'system.status', () => contractNested('system.status', 'disks')],
|
||
['NetInfo', 'system.status', () => contractNested('system.status', 'net')],
|
||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||
['FsListResult', 'fs.list', () => contractTop('fs.list')],
|
||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||
['AuditVerifyResult', 'system.audit.verify', () => contractTop('system.audit.verify')],
|
||
];
|
||
|
||
for (const [iface, capName, contract] of FORWARD_CASES) {
|
||
test(`契约:${iface} 的字段都存在于 ${capName} 的 returns_schema`, () => {
|
||
const declared = fieldsOf(iface);
|
||
const allowed = contract();
|
||
const missing = [...declared].filter((f) => !allowed.has(f));
|
||
assert.deepEqual(
|
||
missing,
|
||
[],
|
||
`${iface} 声明了 ${capName} 契约中不存在的字段:${missing.join(', ')}\n` +
|
||
`(契约字段:${[...allowed].join(', ')})\n` +
|
||
`👉 这类漂移不会报错,只会让界面出现**静默空列**。`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------- 契约 ⊆ 声明(抓「契约加了字段、前端没跟上」) ----------
|
||
|
||
/**
|
||
* 只对 returns_schema **完整枚举**属性的能力做反向校验,
|
||
* 否则 `type: 'object'` 的空壳(如 system.audit.list 的 entries item)会误报。
|
||
*/
|
||
const REVERSE_CASES = [
|
||
['SystemInfo', 'system.info', () => contractTop('system.info')],
|
||
['SystemStatus', 'system.status', () => contractTop('system.status')],
|
||
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
|
||
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
|
||
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
|
||
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
|
||
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
|
||
];
|
||
|
||
for (const [iface, capName, contract] of REVERSE_CASES) {
|
||
test(`契约:${capName} 的字段都被 ${iface} 覆盖`, () => {
|
||
const declared = fieldsOf(iface);
|
||
const required = contract();
|
||
const missed = [...required].filter((f) => !declared.has(f));
|
||
assert.deepEqual(
|
||
missed,
|
||
[],
|
||
`${capName} 契约里有 ${missed.join(', ')},但 ${iface} 未声明(前端拿不到这些字段)。`,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------- 平台门控清单与 agent 实现一致 ----------
|
||
|
||
test('平台门控:控制台的 WINDOWS_ONLY_CAPS 与被控端抛 UNSUPPORTED_PLATFORM 的能力一致', () => {
|
||
const storeSrc = readFileSync(CONN_STORE, 'utf8');
|
||
const m = /WINDOWS_ONLY_CAPS\s*=\s*\[([^\]]*)\]/.exec(storeSrc);
|
||
assert.ok(m, '未在 conn.ts 找到 WINDOWS_ONLY_CAPS');
|
||
const declared = new Set(
|
||
m[1]
|
||
.split(',')
|
||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||
.filter(Boolean),
|
||
);
|
||
|
||
// 从被控端实现里找平台门控点。
|
||
// ⚠️ 2026-10-25(v2.1.0):扫描范围原只有 app.ts + system.ts 两种写法
|
||
// (requireWindows(...) 与「仅在被控端为 Windows 时可用」字样)。
|
||
// audit-l2.ts 的门控是裸 throw UNSUPPORTED_PLATFORM,两种模式都扫不到 →
|
||
// 把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而会让本守卫报红(少扫一个文件
|
||
// 就「清单不一致」)。现 audit-l2.ts 已改用统一 assertWindows,且本守卫同步
|
||
// 纳入该文件与 assertWindows 模式 —— 覆盖面扩大,不是放行。
|
||
const gated = new Set();
|
||
for (const rel of [
|
||
['apps', 'agent', 'src', 'capabilities', 'app.ts'],
|
||
['apps', 'agent', 'src', 'capabilities', 'system.ts'],
|
||
['apps', 'agent', 'src', 'capabilities', 'audit-l2.ts'],
|
||
]) {
|
||
const src = readFileSync(join(repoRoot, ...rel), 'utf8');
|
||
// requireWindows('app.xxx') 与 `${capability} 仅在被控端为 Windows 时可用` 两种写法
|
||
for (const mm of src.matchAll(/requireWindows\(\s*['"]([\w.]+)['"]/g)) gated.add(mm[1]);
|
||
// util/platform.ts 的统一门控(assertWindows → assertPlatform(cap, 'win32'))
|
||
for (const mm of src.matchAll(/assertWindows\(\s*['"]([\w.]+)['"]/g)) gated.add(mm[1]);
|
||
for (const mm of src.matchAll(/['"]([\w.]+) 仅在被控端为 Windows 时可用['"]/g)) {
|
||
// 该文件里 system.service.list 是以字面量写的;app.ts 是模板串(已由上面捕获)
|
||
if (mm[1].includes('.')) gated.add(mm[1]);
|
||
}
|
||
}
|
||
|
||
const declaredArr = [...declared].sort();
|
||
const gatedArr = [...gated].sort();
|
||
assert.deepEqual(
|
||
declaredArr,
|
||
gatedArr,
|
||
'控制台的平台门控清单与被控端实际门控的能力不一致:\n' +
|
||
` 控制台声明:${declaredArr.join(', ')}\n` +
|
||
` 被控端实际:${gatedArr.join(', ')}`,
|
||
);
|
||
});
|
||
|
||
// ---------- 审计事件类别与真源一致 ----------
|
||
|
||
test('审计:控制台的 AuditType 覆盖 audit.ts 的全部 AuditType 取值', () => {
|
||
const auditSrc = readFileSync(
|
||
join(repoRoot, 'apps', 'agent', 'src', 'audit.ts'),
|
||
'utf8',
|
||
);
|
||
const union = /export type AuditType\s*=([\s\S]*?);/.exec(auditSrc);
|
||
assert.ok(union, '未在 audit.ts 找到 AuditType 联合类型');
|
||
const real = new Set(
|
||
[...union[1].matchAll(/'([\w.]+)'/g)].map((mm) => mm[1]),
|
||
);
|
||
assert.ok(real.size >= 8, `AuditType 解析异常,仅取到 ${real.size} 个`);
|
||
|
||
const declared = fieldsOf('AuditEntry');
|
||
assert.ok(declared.size > 0, 'AuditEntry 解析失败');
|
||
// union 类型不是 interface —— 直接正则取类型字面量
|
||
const m = /export type AuditType\s*=([\s\S]*?);/.exec(
|
||
readFileSync(CONSOLE_TYPES, 'utf8'),
|
||
);
|
||
assert.ok(m, '未在控制台 types/index.ts 找到 AuditType');
|
||
const consoleTypes = new Set([...m[1].matchAll(/"([\w.]+)"/g)].map((mm) => mm[1]));
|
||
|
||
const missed = [...real].filter((t) => !consoleTypes.has(t));
|
||
assert.deepEqual(
|
||
missed,
|
||
[],
|
||
`控制台 AuditType 缺少:${missed.join(', ')}(真源:${[...real].join(', ')})`,
|
||
);
|
||
});
|