/** * pi-fence audit log (PLAN §9). * * Append-only, newline-delimited JSON at `~/.pi/agent/pi-fence.log`. Daily * rotation, 7-day retention. Every block decision the FS layer makes is * recorded here, regardless of whether the UI prompted or not — the * append path is the single audit funnel. * * Bash-side denials are *not* in this log. Fence has its own monitor * (`fence -m`) for that. See README's Trade-offs section. * * Schema (one entry per line): * * { * "ts": "2026-05-10T14:30:01.123Z", * "session": "", * "decision": "block" | "grant-once" | "grant-session" | "grant-persist", * "tool": "read", * "path": "/Users/andri/.ssh/id_rsa", * "rule": "filesystem.defaultDenyRead", * "pattern": null * } * * Failure mode: if the log can't be appended (disk full, permission * error, audit-log path itself in mandatory deny — paranoid recursion), * we swallow the error. The audit log is best-effort by design; never * block the agent because we couldn't record a decision. */ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; export const AUDIT_DIR = path.join(os.homedir(), ".pi", "agent"); export const AUDIT_FILE = path.join(AUDIT_DIR, "pi-fence.log"); const RETENTION_DAYS = 7; export type AuditDecision = "block" | "grant-once" | "grant-session" | "grant-persist"; export interface AuditEntry { ts: string; session: string | undefined; decision: AuditDecision; tool: string; path: string; rule: string; pattern: string | null; } /** * Append a single entry to the audit log. Errors are swallowed; the audit * log is best-effort and must never block the agent. */ export function append(entry: AuditEntry): void { try { ensureDir(); rotateIfNeeded(); appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf-8"); } catch { // Intentionally swallowed (see file-level comment). } } /** * Tail the last `n` entries from the current log file, parsed. * * Used by `/fence log [N]`. Returns oldest-first within the slice. * Malformed lines are skipped silently. Reads only the current day's * file (rotated files aren't included; we want today-by-default for * the slash command, with a clean small output). */ export function tail(n: number): AuditEntry[] { try { if (!existsSync(AUDIT_FILE)) return []; const text = readFileSync(AUDIT_FILE, "utf-8"); const lines = text.split("\n").filter((l) => l.trim().length > 0); const slice = lines.slice(-n); const out: AuditEntry[] = []; for (const ln of slice) { try { out.push(JSON.parse(ln) as AuditEntry); } catch { // skip malformed } } return out; } catch { return []; } } function ensureDir(): void { if (!existsSync(AUDIT_DIR)) mkdirSync(AUDIT_DIR, { recursive: true }); } /** * On the first append of a UTC day, rotate `pi-fence.log` to a dated * suffix and prune files older than `RETENTION_DAYS`. * * The rotation key is the mtime of the log file in UTC. If today's * date differs from the file's mtime date, rotate. This is robust to * processes that ran across midnight: the next append after midnight * triggers rotation. */ function rotateIfNeeded(): void { if (!existsSync(AUDIT_FILE)) return; let st; try { st = statSync(AUDIT_FILE); } catch { return; } const fileDay = utcDate(st.mtime); const today = utcDate(new Date()); if (fileDay === today) return; const rotated = `${AUDIT_FILE}.${fileDay}`; try { renameSync(AUDIT_FILE, rotated); } catch { // If rename fails, we'd rather keep appending to the existing file // than crash. Bail out of rotation. return; } pruneOld(); } /** * Delete rotated logs whose embedded date is older than the retention * window. We only touch files matching `pi-fence.log.YYYY-MM-DD` to * avoid clobbering anything else in the directory. */ function pruneOld(): void { let entries: string[]; try { entries = readdirSync(AUDIT_DIR); } catch { return; } const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - RETENTION_DAYS); const cutoffStr = utcDate(cutoff); for (const name of entries) { const m = /^pi-fence\.log\.(\d{4}-\d{2}-\d{2})$/.exec(name); if (!m || !m[1]) continue; if (m[1] < cutoffStr) { try { unlinkSync(path.join(AUDIT_DIR, name)); } catch { // ignore } } } } function utcDate(d: Date): string { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); return `${y}-${m}-${day}`; }