决策「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 全绿
134 lines
7.5 KiB
JavaScript
134 lines
7.5 KiB
JavaScript
/**
|
||
* 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');
|
||
});
|