[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
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}