Files
nodeagent/tests/unit/console-l2.test.mjs
T
jkooe 5a2c5f9c55 feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「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 全绿
2026-10-26 00:28:44 +08:00

134 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* L2 系统级审计契约守卫(v2.1.0,T04)。
*
* 背景:L2 能力(`system.audit.l2`)此前只存在于被控端实现里,三端**没有暴露**:
* - MCP 侧没有 `na_audit_l2` 工具 —— 模型永远看不到这个能力;
* - 控制台没有入口、没有类型 —— 用户点不到;
* - 审计事件类型写成 `agent.start`(与进程生命周期混为一类,筛不出来)。
* 本文件把「三端都暴露且互相咬合」钉死:
* ① 控制台 AuditL2Result 字段 ⊆ manifest AuditL2 returns_schema(防静默空列);
* ② returns_schema 关键字段 ⊇ 前端渲染用到的字段(防契约缺字段);
* ③ MCP 工具 na_audit_l2 存在且映射到 CapabilityNames.AuditL2;
* ④ agent.l2 审计类型四端同源(protocol 真源 / agent 联合 / 写入点 / 控制台联合);
* ⑤ 界面纪律:L2 卡片说清信任边界、启用策略有二次确认、非 Windows 门控。
*/
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 AUDIT_VIEW = join(repoRoot, 'apps', 'console', 'src', 'views', 'AuditView.vue');
const MCP_DEFS = join(repoRoot, 'apps', 'mcp', 'src', 'tools', 'defs.ts');
const MCP_INDEX = join(repoRoot, 'apps', 'mcp', 'src', 'index.ts');
const AUDIT_TYPES_SRC = join(repoRoot, 'packages', 'protocol', 'src', 'audit-types.ts');
const AGENT_AUDIT_SRC = join(repoRoot, 'apps', 'agent', 'src', 'audit.ts');
const AUDIT_L2_SRC = join(repoRoot, 'apps', 'agent', 'src', 'capabilities', 'audit-l2.ts');
/** 从 types/index.ts 里抠一个 interface 的字段名集合(与 console-contract 同款解析)。 */
function fieldsOf(interfaceName, file = CONSOLE_TYPES) {
const src = readFileSync(file, 'utf8');
const re = new RegExp(`export interface ${interfaceName}\\s*\\{([\\s\\S]*?)\\n\\}`);
const m = re.exec(src);
assert.ok(m, `未在 ${file} 找到 interface ${interfaceName}`);
const body = m[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
return new Set([...body.matchAll(/^\s*([a-z_][a-z0-9_]*)\s*\??:/gm)].map((mm) => mm[1]));
}
test('L2①:控制台 AuditL2Result 字段 ⊆ manifest returns_schema(防静默空列)', () => {
const cap = findCapability('system.audit.l2');
assert.ok(cap, 'manifest 缺少 system.audit.l2');
const schemaProps = Object.keys(cap.returns_schema?.properties ?? {});
assert.ok(schemaProps.length >= 15, `returns_schema 属性过少(${schemaProps.length}),不像四 action 并集`);
const declared = fieldsOf('AuditL2Result');
assert.ok(declared.size >= 15, `AuditL2Result 解析异常,仅 ${declared.size} 个字段`);
const missing = [...declared].filter((f) => !schemaProps.includes(f));
assert.deepEqual(missing, [], `控制台声明了契约里不存在的字段:${missing.join(', ')}`);
});
test('L2②:returns_schema 关键字段前端都渲染(防契约缺字段 / 前端漏读)', () => {
const cap = findCapability('system.audit.l2');
const schemaProps = new Set(Object.keys(cap.returns_schema?.properties ?? {}));
// 四组 action 各至少两个代表字段必须同时出现在「契约」与「前端字段集」
const mustHave = [
'platform', // status
'supported',
'legacy_policy_disabled',
'cmdline_logging',
'enabled', // enable
'policies',
'collected', // collect
'written',
'ok', // verify
'checked',
];
const declared = fieldsOf('AuditL2Result');
for (const f of mustHave) {
assert.ok(schemaProps.has(f), `returns_schema 缺少 ${f}`);
assert.ok(declared.has(f), `前端 AuditL2Result 未覆盖 ${f}(界面会静默缺列)`);
}
});
test('L2③:MCP 工具 na_audit_l2 存在、紧随 na_audit、且映射到 system.audit.l2', () => {
const defs = readFileSync(MCP_DEFS, 'utf8');
const names = [...defs.matchAll(/^\s*name: '(na_[a-z0-9_]+)',/gm)].map((m) => m[1]);
assert.ok(names.includes('na_audit_l2'), 'defs.ts 缺少 na_audit_l2');
assert.equal(
names[names.indexOf('na_audit') + 1],
'na_audit_l2',
'na_audit_l2 应紧随 na_audit(语义相邻,便于模型选用)',
);
const idx = readFileSync(MCP_INDEX, 'utf8');
assert.match(idx, /na_audit_l2:\s*CapabilityNames\.AuditL2/, 'index.ts 缺少 na_audit_l2 → AuditL2 映射');
// 四 action 必须在 inputSchema enum 里(与 manifest params_schema 一致)
const block = /name: 'na_audit_l2',([\s\S]*?)\n \},/.exec(defs);
assert.ok(block, '未取到 na_audit_l2 定义块');
for (const a of ['status', 'enable', 'collect', 'verify']) {
assert.ok(block[1].includes(`'${a}'`), `na_audit_l2 inputSchema 缺少 action=${a}`);
}
});
test('L2④:agent.l2 审计类型四端同源', () => {
// ① protocol 真源
const types = readFileSync(AUDIT_TYPES_SRC, 'utf8');
assert.match(types, /'agent\.l2'/, 'protocol AUDIT_TYPES 缺少 agent.l2');
// ② agent 联合类型(AssertExact 守卫保证与真源一致,这里再钉文字)
const agentAudit = readFileSync(AGENT_AUDIT_SRC, 'utf8');
assert.match(agentAudit, /\|\s*'agent\.l2'/, 'agent audit.ts 的 AuditType 缺少 agent.l2');
// ③ 写入点:audit-l2.ts 必须写 agent.l2,不得再写 agent.start(混类则筛不出来)
const l2 = readFileSync(AUDIT_L2_SRC, 'utf8');
const l2AuditTypes = [...l2.matchAll(/audit\(\{\s*type:\s*'([\w.]+)'/g)].map((m) => m[1]);
assert.ok(l2AuditTypes.length >= 2, `audit-l2.ts 审计写入点解析异常:${l2AuditTypes.join(',')}`);
for (const t of l2AuditTypes) {
assert.equal(t, 'agent.l2', `audit-l2.ts 的审计类型应为 agent.l2,实际 ${t}`);
}
// ④ 控制台联合类型
const consoleTypes = readFileSync(CONSOLE_TYPES, 'utf8');
const union = /export type AuditType\s*=([\s\S]*?);/.exec(consoleTypes);
assert.ok(union, '控制台缺少 AuditType 联合');
assert.match(union[1], /"agent\.l2"/, '控制台 AuditType 缺少 agent.l2(筛选下拉与实际写入会漂移)');
});
test('L2⑤:界面纪律 —— 信任边界如实说明 + 启用策略二次确认 + 非 Windows 门控', () => {
const view = readFileSync(AUDIT_VIEW, 'utf8');
// 信任边界:不得夸大本地副本的防篡改能力(采集器与 agent 同权限)
assert.match(view, /转发出机/, 'L2 卡片必须说明「防篡改依赖转发出机」(不得夸大本地副本)');
assert.match(view, /绕过 agent/, 'L2 卡片必须说明它补的是「绕过 agent」这块盲区');
// 启用策略是提权危险操作:必须包在二次确认里
assert.match(view, /n-popconfirm[\s\S]*runL2\('enable'\)/, 'enable 必须经 n-popconfirm 二次确认');
assert.match(view, /管理员权限/, 'enable 确认文案必须写明需要管理员权限');
// 平台门控:非 Windows 被控端上按钮禁用 + 明示原因
assert.match(view, /conn\.platformReady/, 'L2 按钮必须受 conn.platformReady 门控');
assert.match(view, /conn\.nodeOs && !conn\.isWindows/, '非 Windows 被控端必须给出明确提示');
const conn = readFileSync(CONN_STORE, 'utf8');
assert.match(conn, /"system\.audit\.l2"/, 'conn.ts WINDOWS_ONLY_CAPS 必须包含 system.audit.l2');
});