flake-update-20260505
  1/**
  2 * Security & Path Validator Extension for Pi
  3 *
  4 * Combines path validation policies with command security:
  5 * - Configurable path policies (match patterns, allowed/blocked paths)
  6 * - Dangerous command detection (rm -rf, sudo, mkfs, dd, fork bombs, ...)
  7 * - Nix-specific guardrails (nix eval, nix-build, flake updates, etc.)
  8 * - Protected path writes (via bash redirects, cp, mv, tee)
  9 * - Soft-protected lockfiles (confirm before modifying)
 10 * - Per-command scoped approvals (once, per-turn, per-session)
 11 * - Actions: warn, block, confirm, or suggest redirect
 12 *
 13 * Policy config: ~/.config/ai/path-policies.json
 14 */
 15
 16import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 17import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
 18import { existsSync } from "node:fs";
 19import { readFile, writeFile } from "node:fs/promises";
 20import { join, normalize } from "node:path";
 21import { homedir } from "node:os";
 22import {
 23	stripQuotedContent,
 24	commandRules,
 25	dangerousBashWrites,
 26	protectedPaths,
 27	softProtectedPaths,
 28	buildModifyReason,
 29	buildRejectReason,
 30	buildWriteModifyReason,
 31	buildWriteRejectReason,
 32} from "./utils";
 33
 34// ── Policy types ──────────────────────────────────────────────
 35
 36interface PathPolicy {
 37	name: string;
 38	description?: string;
 39	filenamePattern?: string;
 40	pathPattern?: string;
 41	allowedPaths?: string[];
 42	blockedPaths?: string[];
 43	requiredFilenameFormat?: string;
 44	formatDescription?: string;
 45	formatExample?: string;
 46	suggestedPath?: string;
 47	action?: "warn" | "block" | "suggest";
 48	enabled?: boolean;
 49}
 50
 51interface PolicyConfig {
 52	policies: PathPolicy[];
 53}
 54
 55// ── Helpers ───────────────────────────────────────────────────
 56
 57function expandHome(path: string): string {
 58	if (path.startsWith("~/")) {
 59		return join(homedir(), path.slice(2));
 60	}
 61	return path;
 62}
 63
 64function pathMatches(path: string, pattern: string): boolean {
 65	const expandedPattern = expandHome(pattern);
 66	const regexPattern = expandedPattern
 67		.replace(/[.+^${}()|[\]\\]/g, "\\$&")
 68		.replace(/\*/g, ".*")
 69		.replace(/\?/g, ".");
 70	return new RegExp(`^${regexPattern}`).test(path);
 71}
 72
 73// ── Default path policies ─────────────────────────────────────
 74
 75const DEFAULT_POLICIES: PathPolicy[] = [
 76	{
 77		name: "ai-sessions",
 78		description: "Session files should go to unified AI storage",
 79		filenamePattern: ".*session.*\\.md$",
 80		blockedPaths: [
 81			"~/.config/claude/history/sessions/",
 82			"~/.claude/history/sessions/",
 83		],
 84		suggestedPath: "~/.local/share/ai/sessions/",
 85		action: "warn",
 86		enabled: true,
 87	},
 88	{
 89		name: "ai-plans",
 90		description: "Plan files should go to unified AI storage",
 91		filenamePattern: ".*plan.*\\.md$",
 92		blockedPaths: [
 93			"~/.config/claude/plans/",
 94			"~/.claude/plans/",
 95		],
 96		suggestedPath: "~/.local/share/ai/plans/",
 97		action: "warn",
 98		enabled: true,
 99	},
100	{
101		name: "ai-learnings",
102		description: "Learning files should go to unified AI storage",
103		filenamePattern: ".*learning.*\\.md$",
104		blockedPaths: [
105			"~/.config/claude/history/learnings/",
106		],
107		suggestedPath: "~/.local/share/ai/learnings/",
108		action: "warn",
109		enabled: true,
110	},
111	{
112		name: "ai-research",
113		description: "Research files should go to unified AI storage",
114		filenamePattern: ".*research.*\\.md$",
115		blockedPaths: [
116			"~/.config/claude/history/research/",
117		],
118		suggestedPath: "~/.local/share/ai/research/",
119		action: "warn",
120		enabled: true,
121	},
122	{
123		name: "no-secrets-in-repos",
124		description: "Prevent writing secrets to git repositories",
125		filenamePattern: "\\.(env|pem|key|secret|credentials)$",
126		blockedPaths: [
127			"~/src/*",
128			"~/projects/*",
129		],
130		action: "block",
131		enabled: true,
132	},
133	{
134		name: "protect-nixpkgs",
135		description: "Warn when writing outside standard locations in nixpkgs",
136		pathPattern: ".*/nixpkgs/(?!pkgs/).*",
137		allowedPaths: [
138			"*/nixpkgs/pkgs/",
139		],
140		action: "warn",
141		enabled: false,
142	},
143];
144
145// ── Approval bypass types ─────────────────────────────────────
146
147type ApprovalScope = "session" | "turn";
148
149interface ApprovalBypass {
150	/** Which dangerous command description was approved */
151	desc: string;
152	/** How long the approval lasts */
153	scope: ApprovalScope;
154}
155
156// ── Extension entry point ─────────────────────────────────────
157
158export default function (pi: ExtensionAPI) {
159	const CONFIG_PATH = join(homedir(), ".config", "ai", "path-policies.json");
160	let policies: PathPolicy[] = DEFAULT_POLICIES;
161	let configLoaded = false;
162
163	// Track approved dangerous commands by scope
164	const approvedCommands: ApprovalBypass[] = [];
165
166	function isApproved(desc: string): boolean {
167		return approvedCommands.some((a) => a.desc === desc);
168	}
169
170	function clearTurnApprovals(): void {
171		for (let i = approvedCommands.length - 1; i >= 0; i--) {
172			if (approvedCommands[i].scope === "turn") {
173				approvedCommands.splice(i, 1);
174			}
175		}
176	}
177
178	function clearAllApprovals(): void {
179		approvedCommands.length = 0;
180	}
181
182	async function loadPolicies(): Promise<void> {
183		if (configLoaded) return;
184		try {
185			if (existsSync(CONFIG_PATH)) {
186				const content = await readFile(CONFIG_PATH, "utf-8");
187				const config: PolicyConfig = JSON.parse(content);
188				if (config.policies && Array.isArray(config.policies)) {
189					const configPolicyNames = new Set(config.policies.map((p) => p.name));
190					policies = [
191						...config.policies,
192						...DEFAULT_POLICIES.filter((p) => !configPolicyNames.has(p.name)),
193					];
194				}
195			}
196		} catch (error) {
197			console.error(`[guardrails] Error loading config: ${error}`);
198		}
199		configLoaded = true;
200	}
201
202	function validatePath(filePath: string): {
203		valid: boolean;
204		violations: Array<{ policy: PathPolicy; reason: string }>;
205	} {
206		const expandedPath = expandHome(filePath);
207		const filename = expandedPath.split("/").pop() || "";
208		const violations: Array<{ policy: PathPolicy; reason: string }> = [];
209
210		for (const policy of policies) {
211			if (policy.enabled === false) continue;
212
213			let matchesPattern = false;
214			if (policy.filenamePattern) {
215				matchesPattern = new RegExp(policy.filenamePattern, "i").test(filename);
216			}
217			if (policy.pathPattern) {
218				matchesPattern = matchesPattern || new RegExp(policy.pathPattern, "i").test(expandedPath);
219			}
220
221			if (!matchesPattern) continue;
222
223			if (policy.blockedPaths) {
224				for (const blockedPath of policy.blockedPaths) {
225					if (pathMatches(expandedPath, blockedPath)) {
226						violations.push({ policy, reason: `Path matches blocked pattern: ${blockedPath}` });
227						break;
228					}
229				}
230			}
231
232			if (policy.allowedPaths && policy.allowedPaths.length > 0) {
233				const isAllowed = policy.allowedPaths.some((p) => pathMatches(expandedPath, p));
234				if (!isAllowed) {
235					violations.push({ policy, reason: `Path not in allowed locations: ${policy.allowedPaths.join(", ")}` });
236				}
237			}
238
239			if (policy.requiredFilenameFormat) {
240				if (!new RegExp(policy.requiredFilenameFormat).test(filename)) {
241					const formatDesc = policy.formatDescription || policy.requiredFilenameFormat;
242					const example = policy.formatExample ? ` (e.g., ${policy.formatExample})` : "";
243					violations.push({ policy, reason: `Filename doesn't match required format: ${formatDesc}${example}` });
244				}
245			}
246		}
247
248		return { valid: violations.length === 0, violations };
249	}
250
251	function formatViolation(
252		violation: { policy: PathPolicy; reason: string },
253		filePath: string,
254		theme: any
255	): string[] {
256		const lines: string[] = [];
257		const policy = violation.policy;
258		const action = policy.action || "warn";
259		const icon = action === "block" ? "🛑" : "⚠️";
260		const actionLabel = action === "block" ? "BLOCKED" : "WARNING";
261		const color = action === "block" ? "error" : "warning";
262
263		lines.push(theme.fg(color, `${icon} ${actionLabel}: ${policy.name}`));
264		if (policy.description) {
265			lines.push(theme.fg("dim", `   ${policy.description}`));
266		}
267		lines.push(theme.fg("dim", `   ${violation.reason}`));
268		lines.push(theme.fg("dim", `   Path: ${filePath}`));
269		if (policy.suggestedPath) {
270			lines.push(theme.fg("accent", `   → Suggested: ${policy.suggestedPath}`));
271		}
272		return lines;
273	}
274
275	// ── Tool call hook: bash commands + file writes ───────────
276
277	pi.on("tool_call", async (event, ctx) => {
278		await loadPolicies();
279
280		// ── Bash command security ──
281		if (isToolCallEventType("bash", event)) {
282			const command = event.input.command;
283			const commandForMatching = stripQuotedContent(command);
284
285			// Check command rules (block or confirm before allowing)
286			for (const rule of commandRules) {
287				if (rule.pattern.test(commandForMatching)) {
288					// Hard block: no approval possible
289					if (rule.action === "block") {
290						const reason = rule.suggestion
291							? `${rule.desc}: ${rule.suggestion}`
292							: rule.desc;
293						ctx.ui.notify(`🚫 BLOCKED: ${reason}`, "error");
294						return { block: true, reason };
295					}
296
297					// Confirm: skip prompt if already approved for this scope
298					if (isApproved(rule.desc)) break;
299
300					if (!ctx.hasUI) {
301						return { block: true, reason: `Blocked ${rule.desc} (no UI to confirm)` };
302					}
303
304					const prompt = rule.suggestion
305						? `⚠️ ${rule.desc}\n💡 ${rule.suggestion}\n\n  ${command}\n\nAllow?`
306						: `⚠️ Dangerous command: ${rule.desc}\n\n  ${command}\n\nAllow?`;
307
308					const choice = await ctx.ui.select(
309						prompt,
310						["Yes", "Yes, for this turn", "Yes, for this session", "✎ Modify", "✗ Reject"],
311					);
312					if (choice === "✎ Modify") {
313						return { block: true, reason: buildModifyReason(rule.desc) };
314					}
315					if (!choice || choice === "✗ Reject") {
316						return { block: true, reason: buildRejectReason(rule.desc) };
317					}
318					if (choice === "Yes, for this turn") {
319						approvedCommands.push({ desc: rule.desc, scope: "turn" });
320					} else if (choice === "Yes, for this session") {
321						approvedCommands.push({ desc: rule.desc, scope: "session" });
322					}
323					break;
324				}
325			}
326
327			// Check bash writes to protected paths (hard block)
328			for (const pattern of dangerousBashWrites) {
329				if (pattern.test(command)) {
330					ctx.ui.notify(`🛑 Blocked bash write to protected path`, "warning");
331					return { block: true, reason: "Bash command writes to protected path" };
332				}
333			}
334
335			return undefined;
336		}
337
338		// ── Write/Edit path validation ──
339		const toolName = event.toolName?.toLowerCase() || "";
340		if (!["write", "edit", "notebookedit"].includes(toolName)) {
341			return undefined;
342		}
343
344		const input = event.input as Record<string, unknown>;
345		const filePath = (input.file_path || input.filePath || input.path || input.notebook_path) as string | undefined;
346		if (!filePath || typeof filePath !== "string") return undefined;
347
348		const normalizedPath = normalize(filePath);
349
350		// Check hard-protected paths (always block)
351		for (const { pattern, desc } of protectedPaths) {
352			if (pattern.test(normalizedPath)) {
353				ctx.ui.notify(`🛑 Blocked write to ${desc}: ${filePath}`, "warning");
354				return { block: true, reason: `Protected path: ${desc}` };
355			}
356		}
357
358		// Check soft-protected paths (confirm)
359		for (const { pattern, desc } of softProtectedPaths) {
360			if (pattern.test(normalizedPath)) {
361				if (!ctx.hasUI) {
362					return { block: true, reason: `Protected path (no UI): ${desc}` };
363				}
364				const choice = await ctx.ui.select(
365					`⚠️ Modifying ${desc}\n\nAre you sure you want to modify ${filePath}?`,
366					["✓ Accept", "✎ Modify", "✗ Reject"],
367				);
368				if (choice === "✎ Modify") {
369					return { block: true, reason: buildWriteModifyReason(desc, filePath) };
370				}
371				if (!choice || choice === "✗ Reject") {
372					return { block: true, reason: buildWriteRejectReason(desc, filePath) };
373				}
374				break;
375			}
376		}
377
378		// Check configurable path policies
379		const result = validatePath(filePath);
380		if (!result.valid) {
381			const theme = ctx.ui.theme;
382			const blockingViolations = result.violations.filter((v) => v.policy.action === "block");
383			const warningViolations = result.violations.filter((v) => v.policy.action !== "block");
384
385			if (warningViolations.length > 0) {
386				const widgetLines: string[] = [
387					theme.bold("Path Validation Warnings"),
388					theme.fg("dim", "─".repeat(50)),
389				];
390				for (const violation of warningViolations) {
391					widgetLines.push(...formatViolation(violation, filePath, theme));
392					widgetLines.push("");
393				}
394				ctx.ui.setWidget("guardrails", widgetLines);
395				setTimeout(() => ctx.ui.setWidget("guardrails", undefined), 10000);
396			}
397
398			if (blockingViolations.length > 0) {
399				const messages = blockingViolations.map((v) => {
400					let msg = `[${v.policy.name}] ${v.reason}`;
401					if (v.policy.suggestedPath) msg += ` (use ${v.policy.suggestedPath} instead)`;
402					return msg;
403				});
404				return { block: true, reason: messages.join("\n") };
405			}
406		}
407
408		return undefined;
409	});
410
411	// ── Turn/session lifecycle: clear approvals ──────────────
412
413	pi.on("agent_start", async () => {
414		// New user prompt → clear turn-scoped approvals
415		clearTurnApprovals();
416	});
417
418	pi.on("session_start", async () => {
419		// New or restored session → clear all approvals
420		clearAllApprovals();
421	});
422
423	// ── Commands ──────────────────────────────────────────────
424
425	pi.registerCommand("path-policies", {
426		description: "List path policies. Usage: /path-policies [filter] [--verbose]",
427		handler: async (args, ctx) => {
428			await loadPolicies();
429			const theme = ctx.ui.theme;
430
431			const argStr = (args || "").trim();
432			const verbose = argStr.includes("--verbose") || argStr.includes("-v");
433			const filter = argStr.replace(/--verbose|-v/g, "").trim().toLowerCase();
434
435			let filteredPolicies = policies;
436			if (filter) {
437				filteredPolicies = policies.filter((p) =>
438					p.name.toLowerCase().includes(filter) ||
439					p.description?.toLowerCase().includes(filter) ||
440					p.action?.toLowerCase() === filter
441				);
442			}
443
444			if (filteredPolicies.length === 0) {
445				ctx.ui.notify(`No policies matching "${filter}"`, "info");
446				return;
447			}
448
449			const lines: string[] = [
450				theme.bold(`📋 Path Policies${filter ? ` (${filter})` : ""} [${filteredPolicies.length}/${policies.length}]`),
451				theme.fg("dim", "─".repeat(60)),
452			];
453
454			for (const policy of filteredPolicies) {
455				const status = policy.enabled === false ? theme.fg("dim", "off") : theme.fg("success", "on");
456				const action = theme.fg(
457					policy.action === "block" ? "error" : "warning",
458					policy.action || "warn"
459				);
460				lines.push(`${theme.fg("accent", "•")} ${theme.bold(policy.name)} [${status}] ${action}`);
461
462				if (verbose) {
463					if (policy.description) lines.push(`  ${theme.fg("dim", policy.description)}`);
464					if (policy.filenamePattern) lines.push(`  ${theme.fg("dim", `Filename: ${policy.filenamePattern}`)}`);
465					if (policy.pathPattern) lines.push(`  ${theme.fg("dim", `Path: ${policy.pathPattern}`)}`);
466					if (policy.requiredFilenameFormat) lines.push(`  ${theme.fg("dim", `Format: ${policy.formatDescription || policy.requiredFilenameFormat}`)}`);
467					if (policy.blockedPaths?.length) lines.push(`  ${theme.fg("dim", `Blocked: ${policy.blockedPaths.join(", ")}`)}`);
468					if (policy.suggestedPath) lines.push(`  ${theme.fg("accent", `${policy.suggestedPath}`)}`);
469					lines.push("");
470				}
471			}
472
473			if (!verbose) lines.push(theme.fg("dim", "Use --verbose for details"));
474			lines.push(theme.fg("dim", `Config: ${CONFIG_PATH}`));
475
476			const tmpFile = `/tmp/path-policies-${Date.now()}.txt`;
477			const plainLines = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, ""));
478			await writeFile(tmpFile, plainLines.join("\n"), "utf-8");
479
480			ctx.ui.setWidget("path-policies", lines);
481			ctx.ui.notify(`Full output: ${tmpFile}`, "info");
482			setTimeout(() => ctx.ui.setWidget("path-policies", undefined), verbose ? 60000 : 20000);
483		},
484	});
485
486	pi.registerCommand("reload-policies", {
487		description: "Reload path validation policies from config file",
488		handler: async (_args, ctx) => {
489			configLoaded = false;
490			await loadPolicies();
491			ctx.ui.notify(`Loaded ${policies.length} path policies`, "info");
492		},
493	});
494}