mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
4896887660
One canonical SIEM-shaped event per action, appended to NDJSON day-files under data/audit/. v1 surfaces: every ncl command (both transports, including scope denials) and every host-routed approval — pending, decision, and terminal outcome — covering CLI gates, self-mod, a2a message gates, agent creation (incl. the ungated global-scope door and the channel-registration name flow), the permissions sender/channel cards, and OneCLI credential holds. Emit architecture is wrappers at module edges, zero inline audit calls: withAudit(dispatch) middleware (trace out-param carries the resolved command + effective args), a decorated requestApproval (owns pending events; inner returns the minted hold), an observer on the existing approval-resolved hook (decision events), a wrapped handler run in the response handler (terminal events; cli_command's terminal comes from the replay through the dispatch middleware, correlated via DispatchOptions.approvalId), and audited() seams for permissions, OneCLI, and performCreateAgent. Gated chains share correlation_id = the approval id. Governance guarantees: off by default (the emitter no-ops and data/audit is never created); fail-open + loud on append failure; boot writability assert when enabled (refuse to start over a silent audit gap); recursive secret-key redaction + ~2KB value truncation at the single emit point; message-bearing events record shape only (body_chars, attachment names). Retention (AUDIT_RETENTION_DAYS, default 90, 0 = forever) hard-deletes day-files at boot + once daily in the host sweep. Read back with ncl audit list (--actor --action --resource --outcome --since --until --correlation --limit, --format ndjson) — host + global scope only; group-scoped agents fail closed via the existing allowlist. Design docs (requirements + grill-session decisions) live on the team hub; docs/SECURITY.md carries the operator-facing documentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
6.4 KiB
TypeScript
161 lines
6.4 KiB
TypeScript
/**
|
|
* Read-back for `ncl audit list` — a newest-first stream-scan over the
|
|
* day-files. No index: fine at v1 volume, and adding one later doesn't change
|
|
* the store. NDJSON export returns the stored lines verbatim.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { AUDIT_ENABLED } from '../config.js';
|
|
import { log } from '../log.js';
|
|
import { AUDIT_DIR, utcDay } from './store.js';
|
|
import type { AuditEvent, AuditOutcome } from './types.js';
|
|
|
|
const OUTCOMES: ReadonlySet<string> = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']);
|
|
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
|
const DEFAULT_LIMIT = 100;
|
|
|
|
export interface AuditQuery {
|
|
actor?: string;
|
|
/** Exact action or dotted prefix (`groups` matches `groups.config.update`). */
|
|
action?: string;
|
|
/** Matches any resources[] entry by id or by type. */
|
|
resource?: string;
|
|
outcome?: AuditOutcome;
|
|
sinceMs?: number;
|
|
untilMs?: number;
|
|
correlation?: string;
|
|
limit: number;
|
|
}
|
|
|
|
/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */
|
|
export function parseTimeFlag(value: string, flag: string): number {
|
|
const rel = /^(\d+)([dhm])$/.exec(value);
|
|
if (rel) {
|
|
const n = Number(rel[1]);
|
|
const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000;
|
|
return Date.now() - n * unitMs;
|
|
}
|
|
const abs = Date.parse(value);
|
|
if (!Number.isNaN(abs)) return abs;
|
|
throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`);
|
|
}
|
|
|
|
/** Newest first across files and within each file, up to q.limit. */
|
|
export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } {
|
|
const events: AuditEvent[] = [];
|
|
const lines: string[] = [];
|
|
let malformed = 0;
|
|
|
|
for (const { day, file } of dayFilesNewestFirst()) {
|
|
if (events.length >= q.limit) break;
|
|
// Whole-day skip: a file can't match a window its day lies outside.
|
|
if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue;
|
|
if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue;
|
|
|
|
let content: string;
|
|
try {
|
|
content = fs.readFileSync(file, 'utf8');
|
|
// eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query
|
|
} catch (err) {
|
|
log.warn('Audit reader failed to read day-file', { file, err });
|
|
continue;
|
|
}
|
|
const fileLines = content.split('\n').filter((l) => l.trim() !== '');
|
|
// Lines within a file are chronological — walk backwards for newest-first.
|
|
for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) {
|
|
let event: AuditEvent;
|
|
try {
|
|
event = JSON.parse(fileLines[i]) as AuditEvent;
|
|
// eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below)
|
|
} catch {
|
|
malformed++;
|
|
continue;
|
|
}
|
|
if (!matches(event, q)) continue;
|
|
events.push(event);
|
|
lines.push(fileLines[i]);
|
|
}
|
|
}
|
|
|
|
if (malformed > 0) {
|
|
log.warn('Audit reader skipped malformed lines', { malformed });
|
|
}
|
|
return { events, lines };
|
|
}
|
|
|
|
function dayFilesNewestFirst(): Array<{ day: string; file: string }> {
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(AUDIT_DIR);
|
|
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error
|
|
} catch {
|
|
return [];
|
|
}
|
|
return entries
|
|
.map((e) => DAY_FILE_RE.exec(e))
|
|
.filter((m): m is RegExpExecArray => m !== null)
|
|
.map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) }))
|
|
.sort((a, b) => (a.day < b.day ? 1 : -1));
|
|
}
|
|
|
|
function matches(event: AuditEvent, q: AuditQuery): boolean {
|
|
if (q.actor !== undefined && event.actor?.id !== q.actor) return false;
|
|
if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false;
|
|
if (q.outcome !== undefined && event.outcome !== q.outcome) return false;
|
|
if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false;
|
|
if (q.resource !== undefined) {
|
|
const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource);
|
|
if (!hit) return false;
|
|
}
|
|
const t = Date.parse(event.time ?? '');
|
|
if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false;
|
|
if (q.untilMs !== undefined && !(t <= q.untilMs)) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* `ncl audit list` handler. Disabled → an explicit error: an empty list would
|
|
* read as "no actions happened", which is a different truth than "not
|
|
* recording". `--format ndjson` returns the stored lines verbatim (the human
|
|
* formatter passes strings through); default returns flat rows for the table.
|
|
*/
|
|
export function listAuditEvents(args: Record<string, unknown>): string | Array<Record<string, unknown>> {
|
|
if (!AUDIT_ENABLED) {
|
|
throw new Error('audit log is disabled — set AUDIT_ENABLED=true');
|
|
}
|
|
|
|
const format = args.format !== undefined ? String(args.format) : '';
|
|
if (format && format !== 'ndjson') {
|
|
throw new Error(`invalid --format "${format}" — only "ndjson" is supported`);
|
|
}
|
|
const outcome = args.outcome !== undefined ? String(args.outcome) : undefined;
|
|
if (outcome !== undefined && !OUTCOMES.has(outcome)) {
|
|
throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`);
|
|
}
|
|
|
|
const q: AuditQuery = {
|
|
actor: args.actor !== undefined ? String(args.actor) : undefined,
|
|
action: args.action !== undefined ? String(args.action) : undefined,
|
|
resource: args.resource !== undefined ? String(args.resource) : undefined,
|
|
outcome: outcome as AuditOutcome | undefined,
|
|
correlation: args.correlation !== undefined ? String(args.correlation) : undefined,
|
|
sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined,
|
|
untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined,
|
|
limit: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_LIMIT,
|
|
};
|
|
|
|
const { events, lines } = queryAuditEvents(q);
|
|
if (format === 'ndjson') return lines.join('\n');
|
|
|
|
return events.map((e) => ({
|
|
time: e.time,
|
|
actor: e.actor?.id ?? '',
|
|
action: e.action,
|
|
resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '),
|
|
outcome: e.outcome,
|
|
correlation: e.correlation_id ?? '',
|
|
event_id: e.event_id,
|
|
}));
|
|
}
|