决策「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 全绿
526 lines
19 KiB
TypeScript
526 lines
19 KiB
TypeScript
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
|
||
import { createHash } from 'node:crypto';
|
||
import { join } from 'node:path';
|
||
import { agentDir } from './config.js';
|
||
import {
|
||
judgeAnchors,
|
||
parseAnchorsFromJsonl,
|
||
AUDIT_TYPES,
|
||
type AnchorComparison,
|
||
type AnchorRecord,
|
||
} from '@nodeagent/protocol';
|
||
|
||
export type AuditType =
|
||
| 'agent.start'
|
||
| 'agent.stop'
|
||
| 'auth.success'
|
||
| 'auth.failure'
|
||
| 'invoke'
|
||
| 'acl.denied'
|
||
| 'capability.disabled'
|
||
| 'rate.limited'
|
||
/** v16:网络配置变更(两阶段提交) */
|
||
| 'net.change'
|
||
/** v21:来源不在 allow_from 被拒 */
|
||
| 'net.deny'
|
||
/** v19:agent 自更新(拉取式) */
|
||
| 'agent.update'
|
||
/**
|
||
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
|
||
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
|
||
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
|
||
*/
|
||
| 'agent.l2'
|
||
/** v2.0.0:连接空闲被主动断开 */
|
||
| 'session.idle_close'
|
||
/**
|
||
* v2.0.3:**被拒的请求**统一留痕。
|
||
*
|
||
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
|
||
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
|
||
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
|
||
*/
|
||
| 'request.rejected'
|
||
/**
|
||
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
|
||
*
|
||
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
|
||
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
|
||
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
|
||
* 混在同一类里,事后无法区分。
|
||
*/
|
||
| 'agent.error';
|
||
|
||
/**
|
||
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
|
||
* `AUDIT_TYPES` **双向一致**。
|
||
*
|
||
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
|
||
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
|
||
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
|
||
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
|
||
*/
|
||
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
||
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
|
||
void _auditTypeInSyncWithProtocol;
|
||
|
||
/** 一条审计记录(JSONL 单行)。 */
|
||
export interface AuditEntry {
|
||
/** Unix 毫秒时间戳 */
|
||
ts: number;
|
||
type: AuditType;
|
||
client_id?: string;
|
||
/** 来源地址 */
|
||
remote?: string;
|
||
capability?: string;
|
||
status?: 'ok' | 'failed' | 'denied';
|
||
duration_ms?: number;
|
||
error?: string | null;
|
||
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
|
||
args_digest?: string;
|
||
/**
|
||
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
|
||
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
|
||
*/
|
||
args_bytes?: number;
|
||
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
|
||
args_truncated?: boolean;
|
||
/** 参数预览(默认不记录;开启后自动脱敏) */
|
||
args_preview?: string;
|
||
reason?: string;
|
||
/** v11 防篡改:上一条记录哈希(首条为 "genesis") */
|
||
prev?: string;
|
||
/** v11 防篡改:本条记录哈希(prev + 规范化正文 的 sha256 前 32 位) */
|
||
hash?: string;
|
||
}
|
||
|
||
export interface AuditOptions {
|
||
enabled: boolean;
|
||
maxBytes: number;
|
||
maxFiles: number;
|
||
/** 是否记录参数预览(默认为 false,只记摘要) */
|
||
logArgs: boolean;
|
||
}
|
||
|
||
const DEFAULTS: AuditOptions = {
|
||
enabled: true,
|
||
maxBytes: 10 * 1024 * 1024,
|
||
maxFiles: 5,
|
||
logArgs: false,
|
||
};
|
||
|
||
let opts: AuditOptions = { ...DEFAULTS };
|
||
let currentPath = '';
|
||
|
||
/** v11:链式哈希锚点(进程内维护;启动时从日志尾部恢复) */
|
||
const GENESIS = 'genesis';
|
||
let lastHash = GENESIS;
|
||
|
||
/** 规范化后计算条目哈希:键排序保证跨进程/跨版本稳定。 */
|
||
function hashEntry(record: Record<string, unknown>, prev: string): string {
|
||
const keys = Object.keys(record).sort();
|
||
const body = JSON.stringify(record, keys);
|
||
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
|
||
}
|
||
|
||
/** 从日志尾部恢复链锚点,保证 agent 重启后链不断。 */
|
||
function recoverChainAnchor(): void {
|
||
try {
|
||
const p = auditFilePath();
|
||
if (!existsSync(p)) {
|
||
lastHash = GENESIS;
|
||
return;
|
||
}
|
||
const lines = readFileSync(p, 'utf8').split('\n');
|
||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||
const line = lines[i]?.trim();
|
||
if (!line) continue;
|
||
const rec = JSON.parse(line) as AuditEntry;
|
||
lastHash = rec.hash ?? GENESIS;
|
||
return;
|
||
}
|
||
lastHash = GENESIS;
|
||
} catch {
|
||
lastHash = GENESIS;
|
||
}
|
||
}
|
||
|
||
/** 初始化审计(返回审计文件路径)。显式 undefined 不会覆盖默认值。 */
|
||
export function initAudit(options: Partial<AuditOptions> = {}, dir?: string): string {
|
||
opts = {
|
||
enabled: options.enabled ?? DEFAULTS.enabled,
|
||
maxBytes: options.maxBytes ?? DEFAULTS.maxBytes,
|
||
maxFiles: options.maxFiles ?? DEFAULTS.maxFiles,
|
||
logArgs: options.logArgs ?? DEFAULTS.logArgs,
|
||
};
|
||
const base = dir ?? agentDir();
|
||
mkdirSync(base, { recursive: true });
|
||
currentPath = join(base, 'audit.log');
|
||
recoverChainAnchor();
|
||
return currentPath;
|
||
}
|
||
|
||
export function auditFilePath(): string {
|
||
return currentPath || join(agentDir(), 'audit.log');
|
||
}
|
||
|
||
const SENSITIVE_KEY = /(pass|pwd|secret|token|key|credential|auth)/i;
|
||
|
||
/** 递归脱敏 + 截断,避免审计本身成为泄露渠道。 */
|
||
function sanitize(value: unknown, depth = 0): unknown {
|
||
if (depth > 3) return '[deep]';
|
||
if (Array.isArray(value)) return value.slice(0, 20).map((v) => sanitize(v, depth + 1));
|
||
if (value !== null && typeof value === 'object') {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||
out[k] = SENSITIVE_KEY.test(k) ? '***' : sanitize(v, depth + 1);
|
||
}
|
||
return out;
|
||
}
|
||
if (typeof value === 'string' && value.length > 200) return `${value.slice(0, 200)}…`;
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* 参数摘要阈值(字节):超过即只哈希前 64KB。
|
||
*
|
||
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
|
||
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
|
||
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
|
||
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
|
||
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
|
||
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
|
||
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
|
||
*/
|
||
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
|
||
|
||
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
|
||
export function describeArgs(args: unknown): {
|
||
args_digest: string;
|
||
args_bytes?: number;
|
||
args_truncated?: boolean;
|
||
args_preview?: string;
|
||
} {
|
||
const json = JSON.stringify(args ?? {});
|
||
const bytes = Buffer.byteLength(json, 'utf8');
|
||
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
|
||
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
|
||
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
|
||
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
|
||
if (!opts.logArgs) return { args_digest: digest, ...base };
|
||
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
|
||
}
|
||
|
||
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
|
||
export function audit(entry: Omit<AuditEntry, 'ts' | 'prev' | 'hash'>): void {
|
||
if (!opts.enabled) return;
|
||
try {
|
||
const full: AuditEntry = { ts: Date.now(), ...entry };
|
||
// v11 链式哈希:prev 指向上一条的 hash,形成不可静默篡改的链
|
||
const record: AuditEntry = { ...full, prev: lastHash };
|
||
record.hash = hashEntry(record as unknown as Record<string, unknown>, lastHash);
|
||
rotateIfNeeded();
|
||
appendFileSync(auditFilePath(), `${JSON.stringify(record)}\n`);
|
||
lastHash = record.hash;
|
||
} catch {
|
||
/* 审计失败不影响业务 */
|
||
}
|
||
}
|
||
|
||
export interface AuditVerifyResult {
|
||
ok: boolean;
|
||
/** 参与校验的条目总数 */
|
||
checked: number;
|
||
/** 无链字段的历史条目数(老版本写入,跳过校验) */
|
||
legacy: number;
|
||
/** 首个异常位置(文件 + 行号 + 原因) */
|
||
broken_at?: { file: string; line: number; reason: string };
|
||
}
|
||
|
||
/**
|
||
* 校验审计链完整性(v11)。
|
||
* 按时间顺序遍历「轮转文件(旧→新)+ 当前文件」,逐条重算哈希并比对 prev 链接。
|
||
* 老版本(无 hash 字段)条目记为 legacy 跳过,保证升级后不误报。
|
||
*/
|
||
export function verifyAudit(): AuditVerifyResult {
|
||
const p = auditFilePath();
|
||
const files: string[] = [];
|
||
for (let i = 20; i >= 1; i -= 1) {
|
||
const f = `${p}.${i}`;
|
||
if (existsSync(f)) files.push(f);
|
||
}
|
||
files.push(p);
|
||
|
||
let checked = 0;
|
||
let legacy = 0;
|
||
// null = 尚未锚定。轮转会丢弃最旧文件,故首个带链条目只作锚点、不校验 prev;
|
||
// 之后逐条比对 prev,链中间被篡改/删除必被发现。
|
||
let expected: string | null = null;
|
||
|
||
for (const f of files) {
|
||
if (!existsSync(f)) continue;
|
||
const lines = readFileSync(f, 'utf8').split('\n');
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const raw = lines[i]?.trim();
|
||
if (!raw) continue;
|
||
let rec: AuditEntry;
|
||
try {
|
||
rec = JSON.parse(raw) as AuditEntry;
|
||
} catch {
|
||
return { ok: false, checked, legacy, broken_at: { file: f, line: i + 1, reason: 'JSON 解析失败(条目被破坏)' } };
|
||
}
|
||
if (!rec.hash) {
|
||
legacy += 1;
|
||
expected = null; // 链接锚点未知,等待下一条带链字段的记录重新锚定
|
||
continue;
|
||
}
|
||
const { hash, ...body } = rec;
|
||
const recalculated = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
|
||
if (recalculated !== hash) {
|
||
return {
|
||
ok: false,
|
||
checked,
|
||
legacy,
|
||
broken_at: { file: f, line: i + 1, reason: '内容与哈希不符(条目被篡改)' },
|
||
};
|
||
}
|
||
if (expected !== null && rec.prev !== expected) {
|
||
return {
|
||
ok: false,
|
||
checked,
|
||
legacy,
|
||
broken_at: { file: f, line: i + 1, reason: '链断裂(prev 与上一条哈希不匹配,可能被删除或替换)' },
|
||
};
|
||
}
|
||
expected = hash;
|
||
checked += 1;
|
||
}
|
||
}
|
||
return { ok: true, checked, legacy };
|
||
}
|
||
|
||
/** 大小超限时轮转:audit.log → .1 → .2 …(超出 maxFiles 的最旧文件被丢弃)。 */
|
||
function rotateIfNeeded(): void {
|
||
const p = auditFilePath();
|
||
if (!existsSync(p)) return;
|
||
try {
|
||
if (statSync(p).size < opts.maxBytes) return;
|
||
for (let i = opts.maxFiles - 1; i >= 1; i -= 1) {
|
||
const from = `${p}.${i}`;
|
||
if (existsSync(from)) {
|
||
try {
|
||
renameSync(from, `${p}.${i + 1}`);
|
||
} catch {
|
||
/* 忽略单个文件轮转失败 */
|
||
}
|
||
}
|
||
}
|
||
renameSync(p, `${p}.1`);
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
}
|
||
|
||
export interface AuditQuery {
|
||
limit?: number;
|
||
since?: number;
|
||
clientId?: string;
|
||
type?: string;
|
||
}
|
||
|
||
function readEntriesFromFile(file: string): AuditEntry[] {
|
||
if (!existsSync(file)) return [];
|
||
const out: AuditEntry[] = [];
|
||
try {
|
||
const lines = readFileSync(file, 'utf8').split('\n');
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
out.push(JSON.parse(line) as AuditEntry);
|
||
} catch {
|
||
/* 跳过损坏行 */
|
||
}
|
||
}
|
||
} catch {
|
||
/* 单文件读取失败不影响整体 */
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 读取审计记录(取最新 limit 条,按时间升序返回)。
|
||
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
|
||
*/
|
||
/**
|
||
* 读取全部审计条目(跨轮转文件,按时间升序)。
|
||
* 供指标聚合等「需要全窗口样本」的场景使用 —— readAudit 有 limit 上限(最多 1000 条)。
|
||
* 安全上限 20 万条,防止极端情况下把内存吃爆。
|
||
*/
|
||
export function readAuditAll(since?: number): AuditEntry[] {
|
||
const p = auditFilePath();
|
||
const files: string[] = [];
|
||
for (let i = 20; i >= 1; i -= 1) {
|
||
const f = `${p}.${i}`;
|
||
if (existsSync(f)) files.push(f);
|
||
}
|
||
files.push(p);
|
||
|
||
const out: AuditEntry[] = [];
|
||
for (const f of files) {
|
||
for (const e of readEntriesFromFile(f)) {
|
||
if (since !== undefined && e.ts <= since) continue;
|
||
out.push(e);
|
||
if (out.length >= 200_000) break;
|
||
}
|
||
if (out.length >= 200_000) break;
|
||
}
|
||
out.sort((a, b) => a.ts - b.ts);
|
||
return out;
|
||
}
|
||
|
||
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
|
||
const p = auditFilePath();
|
||
if (!existsSync(p)) return { entries: [], total: 0, file: p };
|
||
|
||
const limit = Math.max(1, Math.min(query.limit ?? 50, 1000));
|
||
|
||
// 收集候选文件:当前文件 + 轮转文件(从新到旧)
|
||
const files: string[] = [p];
|
||
for (let i = 1; i <= 20 && existsSync(`${p}.${i}`); i += 1) files.push(`${p}.${i}`);
|
||
|
||
const collected: AuditEntry[] = [];
|
||
let total = 0;
|
||
for (const f of files) {
|
||
const list = readEntriesFromFile(f);
|
||
total += list.length;
|
||
for (const e of list) {
|
||
if (query.since !== undefined && e.ts <= query.since) continue;
|
||
if (query.clientId && e.client_id !== query.clientId) continue;
|
||
if (query.type && !e.type.startsWith(query.type)) continue;
|
||
collected.push(e);
|
||
}
|
||
// 已凑够需要的条数,可提前停止(避免无谓地读完所有轮转文件)
|
||
if (collected.length >= limit) break;
|
||
}
|
||
|
||
collected.sort((a, b) => a.ts - b.ts);
|
||
return { entries: collected.slice(-limit), total, file: p };
|
||
}
|
||
|
||
|
||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||
//
|
||
// ## 为什么需要
|
||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
|
||
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
|
||
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
|
||
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
|
||
//
|
||
// ## 记什么(关键:不能只记哈希)
|
||
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
|
||
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
|
||
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
|
||
|
||
export interface AuditHead {
|
||
/** 当前链可见条目数(含轮转段) */
|
||
entries: number;
|
||
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
|
||
head_hash: string | null;
|
||
/** 末条时间戳 */
|
||
head_ts: number | null;
|
||
/** 存在的轮转段数(.log.1 … .log.20) */
|
||
rotated_segments: number;
|
||
file: string;
|
||
file_bytes: number;
|
||
computed_at: number;
|
||
}
|
||
|
||
/** 计算当前审计链的头部信息(只读)。 */
|
||
export function computeAuditHead(): AuditHead {
|
||
const p = auditFilePath();
|
||
let rotated = 0;
|
||
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
|
||
|
||
const entries = readAuditAll();
|
||
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
|
||
let fileBytes = 0;
|
||
try {
|
||
fileBytes = statSync(p).size;
|
||
} catch {
|
||
/* 文件可能尚不存在(还没写过审计) */
|
||
}
|
||
return {
|
||
entries: entries.length,
|
||
head_hash: last?.hash ?? null,
|
||
head_ts: last?.ts ?? null,
|
||
rotated_segments: rotated,
|
||
file: p,
|
||
file_bytes: fileBytes,
|
||
computed_at: Date.now(),
|
||
};
|
||
}
|
||
|
||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||
|
||
/** 锚点文件路径(默认与审计同目录)。 */
|
||
export function anchorFilePath(custom?: string): string {
|
||
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
|
||
}
|
||
|
||
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
|
||
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||
file: string;
|
||
record: AnchorRecord;
|
||
total_lines: number;
|
||
} {
|
||
const head = computeAuditHead();
|
||
const file = anchorFilePath(opts.path);
|
||
const rec: AnchorRecord = {
|
||
ts: Date.now(),
|
||
entries: head.entries,
|
||
head_hash: head.head_hash,
|
||
head_ts: head.head_ts,
|
||
rotated_segments: head.rotated_segments,
|
||
...(opts.note ? { note: opts.note } : {}),
|
||
};
|
||
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
|
||
let total = 0;
|
||
try {
|
||
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
|
||
} catch {
|
||
total = 1;
|
||
}
|
||
return { file, record: rec, total_lines: total };
|
||
}
|
||
|
||
|
||
/**
|
||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||
*
|
||
* 判定逻辑(三条):
|
||
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
|
||
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
|
||
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
|
||
*/
|
||
export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||
const file = anchorFilePath(customPath);
|
||
if (!existsSync(file)) {
|
||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||
}
|
||
let anchors: AnchorRecord[] | null = null;
|
||
try {
|
||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||
} catch {
|
||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||
}
|
||
if (!anchors) {
|
||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||
}
|
||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||
return judgeAnchors(computeAuditHead(), anchors);
|
||
}
|