[READ-ONLY] Mirror of https://github.com/andrioid/pi-fence. Fence wrapper for Pi coding agent. Wraps bash calls and intercepts reads/writes according to fence config
0

Configure Feed

Select the types of activity you want to include in your feed.

pi-fence / audit.ts
4.7 kB 161 lines
1/** 2 * pi-fence audit log (PLAN §9). 3 * 4 * Append-only, newline-delimited JSON at `~/.pi/agent/pi-fence.log`. Daily 5 * rotation, 7-day retention. Every block decision the FS layer makes is 6 * recorded here, regardless of whether the UI prompted or not — the 7 * append path is the single audit funnel. 8 * 9 * Bash-side denials are *not* in this log. Fence has its own monitor 10 * (`fence -m`) for that. See README's Trade-offs section. 11 * 12 * Schema (one entry per line): 13 * 14 * { 15 * "ts": "2026-05-10T14:30:01.123Z", 16 * "session": "<session-id>", 17 * "decision": "block" | "grant-once" | "grant-session" | "grant-persist", 18 * "tool": "read", 19 * "path": "/Users/andri/.ssh/id_rsa", 20 * "rule": "filesystem.defaultDenyRead", 21 * "pattern": null 22 * } 23 * 24 * Failure mode: if the log can't be appended (disk full, permission 25 * error, audit-log path itself in mandatory deny — paranoid recursion), 26 * we swallow the error. The audit log is best-effort by design; never 27 * block the agent because we couldn't record a decision. 28 */ 29 30import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs"; 31import * as os from "node:os"; 32import * as path from "node:path"; 33 34export const AUDIT_DIR = path.join(os.homedir(), ".pi", "agent"); 35export const AUDIT_FILE = path.join(AUDIT_DIR, "pi-fence.log"); 36const RETENTION_DAYS = 7; 37 38export type AuditDecision = "block" | "grant-once" | "grant-session" | "grant-persist"; 39 40export interface AuditEntry { 41 ts: string; 42 session: string | undefined; 43 decision: AuditDecision; 44 tool: string; 45 path: string; 46 rule: string; 47 pattern: string | null; 48} 49 50/** 51 * Append a single entry to the audit log. Errors are swallowed; the audit 52 * log is best-effort and must never block the agent. 53 */ 54export function append(entry: AuditEntry): void { 55 try { 56 ensureDir(); 57 rotateIfNeeded(); 58 appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf-8"); 59 } catch { 60 // Intentionally swallowed (see file-level comment). 61 } 62} 63 64/** 65 * Tail the last `n` entries from the current log file, parsed. 66 * 67 * Used by `/fence log [N]`. Returns oldest-first within the slice. 68 * Malformed lines are skipped silently. Reads only the current day's 69 * file (rotated files aren't included; we want today-by-default for 70 * the slash command, with a clean small output). 71 */ 72export function tail(n: number): AuditEntry[] { 73 try { 74 if (!existsSync(AUDIT_FILE)) return []; 75 const text = readFileSync(AUDIT_FILE, "utf-8"); 76 const lines = text.split("\n").filter((l) => l.trim().length > 0); 77 const slice = lines.slice(-n); 78 const out: AuditEntry[] = []; 79 for (const ln of slice) { 80 try { 81 out.push(JSON.parse(ln) as AuditEntry); 82 } catch { 83 // skip malformed 84 } 85 } 86 return out; 87 } catch { 88 return []; 89 } 90} 91 92function ensureDir(): void { 93 if (!existsSync(AUDIT_DIR)) mkdirSync(AUDIT_DIR, { recursive: true }); 94} 95 96/** 97 * On the first append of a UTC day, rotate `pi-fence.log` to a dated 98 * suffix and prune files older than `RETENTION_DAYS`. 99 * 100 * The rotation key is the mtime of the log file in UTC. If today's 101 * date differs from the file's mtime date, rotate. This is robust to 102 * processes that ran across midnight: the next append after midnight 103 * triggers rotation. 104 */ 105function rotateIfNeeded(): void { 106 if (!existsSync(AUDIT_FILE)) return; 107 let st; 108 try { 109 st = statSync(AUDIT_FILE); 110 } catch { 111 return; 112 } 113 const fileDay = utcDate(st.mtime); 114 const today = utcDate(new Date()); 115 if (fileDay === today) return; 116 117 const rotated = `${AUDIT_FILE}.${fileDay}`; 118 try { 119 renameSync(AUDIT_FILE, rotated); 120 } catch { 121 // If rename fails, we'd rather keep appending to the existing file 122 // than crash. Bail out of rotation. 123 return; 124 } 125 pruneOld(); 126} 127 128/** 129 * Delete rotated logs whose embedded date is older than the retention 130 * window. We only touch files matching `pi-fence.log.YYYY-MM-DD` to 131 * avoid clobbering anything else in the directory. 132 */ 133function pruneOld(): void { 134 let entries: string[]; 135 try { 136 entries = readdirSync(AUDIT_DIR); 137 } catch { 138 return; 139 } 140 const cutoff = new Date(); 141 cutoff.setUTCDate(cutoff.getUTCDate() - RETENTION_DAYS); 142 const cutoffStr = utcDate(cutoff); 143 for (const name of entries) { 144 const m = /^pi-fence\.log\.(\d{4}-\d{2}-\d{2})$/.exec(name); 145 if (!m || !m[1]) continue; 146 if (m[1] < cutoffStr) { 147 try { 148 unlinkSync(path.join(AUDIT_DIR, name)); 149 } catch { 150 // ignore 151 } 152 } 153 } 154} 155 156function utcDate(d: Date): string { 157 const y = d.getUTCFullYear(); 158 const m = String(d.getUTCMonth() + 1).padStart(2, "0"); 159 const day = String(d.getUTCDate()).padStart(2, "0"); 160 return `${y}-${m}-${day}`; 161}