Four features to complete the open-source governance tier: 1. Policy Presets (presets.ts): Built-in rule sets (permissive, safety, strict, audit-only) users can reference by name. Merge order: preset defaults → top-level overrides → additional rules appended. 2. Rate Limiting (rate-limiter.ts): Sliding window counters per key (tool or category). RateLimiter integrated into PolicyEngine — rate limit rules only match when threshold exceeded within window. Memory-only, resets on session restart. 3. Audit Query (audit.ts filter()): Filter audit records by tool, category, status, target pattern (glob), time range (ISO or relative like "1h"), and limit. CLI: moltbot audit query. 4. Audit Reporter (reporter.ts): Aggregates audit data into tool stats, category breakdown, policy decisions, error summaries, and per-minute timeline. Text and JSON output formats. All wired into index.ts with CLI commands (policy presets, audit query, audit report) and plugin.json schema updated with preset field. 151 tests pass across 9 test files. Co-authored-by: dp-web4 <dp@metalinxx.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
219 lines
6.1 KiB
TypeScript
219 lines
6.1 KiB
TypeScript
/**
|
|
* Audit Trail - Hash-linked chain of action records.
|
|
*
|
|
* Each audit record links to its R6 request and the previous record,
|
|
* creating a verifiable chain of provenance.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { R6Request } from "./r6.js";
|
|
import { globToRegex } from "./matchers.js";
|
|
|
|
export type AuditRecord = {
|
|
recordId: string;
|
|
r6RequestId: string;
|
|
timestamp: string;
|
|
tool: string;
|
|
category: string;
|
|
target?: string;
|
|
result: {
|
|
status: "success" | "error" | "blocked";
|
|
outputHash?: string;
|
|
errorMessage?: string;
|
|
durationMs?: number;
|
|
};
|
|
provenance: {
|
|
sessionId: string;
|
|
actionIndex: number;
|
|
prevRecordHash: string;
|
|
};
|
|
};
|
|
|
|
export type AuditFilter = {
|
|
tool?: string;
|
|
category?: string;
|
|
status?: "success" | "error" | "blocked";
|
|
targetPattern?: string;
|
|
since?: string;
|
|
limit?: number;
|
|
};
|
|
|
|
/**
|
|
* Parse a "since" value: ISO date string or relative duration (e.g. "1h", "30m", "2d").
|
|
* Returns a Date or undefined if unparseable.
|
|
*/
|
|
function parseSince(since: string): Date | undefined {
|
|
// Try relative durations first
|
|
const relMatch = since.match(/^(\d+)\s*(s|m|h|d)$/);
|
|
if (relMatch) {
|
|
const amount = parseInt(relMatch[1]!, 10);
|
|
const unit = relMatch[2]!;
|
|
const ms = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit]!;
|
|
return new Date(Date.now() - amount * ms);
|
|
}
|
|
// Try ISO date
|
|
const d = new Date(since);
|
|
return isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
export class AuditChain {
|
|
private storagePath: string;
|
|
private sessionId: string;
|
|
private prevHash: string = "genesis";
|
|
private recordCount: number = 0;
|
|
|
|
constructor(storagePath: string, sessionId: string) {
|
|
this.storagePath = storagePath;
|
|
this.sessionId = sessionId;
|
|
mkdirSync(join(this.storagePath, "audit"), { recursive: true });
|
|
this.loadExisting();
|
|
}
|
|
|
|
private get filePath(): string {
|
|
return join(this.storagePath, "audit", `${this.sessionId}.jsonl`);
|
|
}
|
|
|
|
private loadExisting(): void {
|
|
if (!existsSync(this.filePath)) return;
|
|
try {
|
|
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
if (!content) return;
|
|
const lines = content.split("\n");
|
|
this.recordCount = lines.length;
|
|
const lastLine = lines[lines.length - 1];
|
|
if (lastLine) {
|
|
this.prevHash = createHash("sha256").update(lastLine).digest("hex").slice(0, 16);
|
|
}
|
|
} catch {
|
|
// Start fresh on error
|
|
}
|
|
}
|
|
|
|
record(r6: R6Request, result: AuditRecord["result"]): AuditRecord {
|
|
const record: AuditRecord = {
|
|
recordId: `audit:${r6.id.slice(3)}`,
|
|
r6RequestId: r6.id,
|
|
timestamp: new Date().toISOString(),
|
|
tool: r6.request.toolName,
|
|
category: r6.request.category,
|
|
target: r6.request.target,
|
|
result,
|
|
provenance: {
|
|
sessionId: this.sessionId,
|
|
actionIndex: r6.role.actionIndex,
|
|
prevRecordHash: this.prevHash,
|
|
},
|
|
};
|
|
|
|
const line = JSON.stringify(record);
|
|
appendFileSync(this.filePath, line + "\n");
|
|
this.prevHash = createHash("sha256").update(line).digest("hex").slice(0, 16);
|
|
this.recordCount++;
|
|
|
|
return record;
|
|
}
|
|
|
|
verify(): { valid: boolean; recordCount: number; errors: string[] } {
|
|
const errors: string[] = [];
|
|
if (!existsSync(this.filePath)) {
|
|
return { valid: true, recordCount: 0, errors: [] };
|
|
}
|
|
|
|
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
if (!content) return { valid: true, recordCount: 0, errors: [] };
|
|
|
|
const lines = content.split("\n");
|
|
let prevHash = "genesis";
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
try {
|
|
const record: AuditRecord = JSON.parse(lines[i]!);
|
|
if (record.provenance.prevRecordHash !== prevHash) {
|
|
errors.push(`Record ${i}: hash mismatch (expected ${prevHash}, got ${record.provenance.prevRecordHash})`);
|
|
}
|
|
prevHash = createHash("sha256").update(lines[i]!).digest("hex").slice(0, 16);
|
|
} catch (e) {
|
|
errors.push(`Record ${i}: parse error`);
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, recordCount: lines.length, errors };
|
|
}
|
|
|
|
get count(): number {
|
|
return this.recordCount;
|
|
}
|
|
|
|
getLast(n: number): AuditRecord[] {
|
|
if (!existsSync(this.filePath)) return [];
|
|
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
if (!content) return [];
|
|
const lines = content.split("\n");
|
|
return lines
|
|
.slice(-n)
|
|
.map((line) => {
|
|
try {
|
|
return JSON.parse(line) as AuditRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((r): r is AuditRecord => r !== null);
|
|
}
|
|
|
|
/** Load all records from the JSONL file. */
|
|
getAll(): AuditRecord[] {
|
|
if (!existsSync(this.filePath)) return [];
|
|
const content = readFileSync(this.filePath, "utf-8").trim();
|
|
if (!content) return [];
|
|
return content
|
|
.split("\n")
|
|
.map((line) => {
|
|
try {
|
|
return JSON.parse(line) as AuditRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((r): r is AuditRecord => r !== null);
|
|
}
|
|
|
|
/** Filter audit records by criteria. */
|
|
filter(criteria: AuditFilter): AuditRecord[] {
|
|
let records = this.getAll();
|
|
const limit = criteria.limit ?? 50;
|
|
|
|
if (criteria.tool) {
|
|
const tool = criteria.tool;
|
|
records = records.filter((r) => r.tool === tool);
|
|
}
|
|
|
|
if (criteria.category) {
|
|
const cat = criteria.category;
|
|
records = records.filter((r) => r.category === cat);
|
|
}
|
|
|
|
if (criteria.status) {
|
|
const status = criteria.status;
|
|
records = records.filter((r) => r.result.status === status);
|
|
}
|
|
|
|
if (criteria.targetPattern) {
|
|
const re = globToRegex(criteria.targetPattern);
|
|
records = records.filter((r) => r.target && re.test(r.target));
|
|
}
|
|
|
|
if (criteria.since) {
|
|
const sinceDate = parseSince(criteria.since);
|
|
if (sinceDate) {
|
|
const sinceMs = sinceDate.getTime();
|
|
records = records.filter((r) => new Date(r.timestamp).getTime() >= sinceMs);
|
|
}
|
|
}
|
|
|
|
return records.slice(-limit);
|
|
}
|
|
}
|