Commit b46ef27eeb60

Vincent Demeester <vincent@sbr.pm>
2026-02-15 22:17:23
feat(pi): rename path-validator to guardrails extension
Merged security.ts patterns into the existing path-validator and renamed it to guardrails. Now combines configurable path policies with dangerous command detection, protected path bash writes, soft-protected lockfiles, and homelab-specific blocks for direct nixos-rebuild and home-manager switch.
1 parent 8f28eb3
Changed files (2)
dots
pi
agent
extensions
dots/pi/agent/extensions/path-validator/index.ts → dots/pi/agent/extensions/guardrails/index.ts
@@ -1,48 +1,36 @@
 /**
- * Path Validator Extension for Pi
+ * Security & Path Validator Extension for Pi
  *
- * Configurable file path validation for AI coding agents.
- * Ensures files are written to correct locations based on defined policies.
- *
- * Features:
- * - Configurable policies (match patterns, allowed/blocked paths)
+ * Combines path validation policies with command security:
+ * - Configurable path policies (match patterns, allowed/blocked paths)
+ * - Dangerous command detection (rm -rf, sudo, mkfs, dd, fork bombs, ...)
+ * - Protected path writes (via bash redirects, cp, mv, tee)
+ * - Soft-protected lockfiles (confirm before modifying)
  * - Actions: warn, block, or suggest redirect
- * - Works with Write, Edit, and similar file operations
  *
- * Usage:
- * - Policies defined in ~/.config/ai/path-policies.json
- * - Or use built-in default policies
+ * Policy config: ~/.config/ai/path-policies.json
  */
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 import { existsSync } from "node:fs";
 import { readFile, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { join, normalize } from "node:path";
 import { homedir } from "node:os";
 
-// Policy definition
+// ── Policy types ──────────────────────────────────────────────
+
 interface PathPolicy {
 	name: string;
 	description?: string;
-	// Pattern to match against filename (not full path)
 	filenamePattern?: string;
-	// Pattern to match against full path
 	pathPattern?: string;
-	// Paths where this file type is allowed
 	allowedPaths?: string[];
-	// Paths where this file type is blocked
 	blockedPaths?: string[];
-	// Required filename format (regex) - blocks if doesn't match
 	requiredFilenameFormat?: string;
-	// Human-readable description of required format
 	formatDescription?: string;
-	// Example of valid filename
 	formatExample?: string;
-	// Suggested correct path (for redirects)
 	suggestedPath?: string;
-	// Action: "warn" (default), "block", or "suggest"
 	action?: "warn" | "block" | "suggest";
-	// Whether this policy is enabled
 	enabled?: boolean;
 }
 
@@ -50,7 +38,56 @@ interface PolicyConfig {
 	policies: PathPolicy[];
 }
 
-// Expand ~ to home directory
+// ── Dangerous command patterns ────────────────────────────────
+
+const dangerousCommands = [
+	{ pattern: /\brm\s+(-[^\s]*r|--recursive)/, desc: "recursive delete" },
+	{ pattern: /\bsudo\b/, desc: "sudo command" },
+	{ pattern: /\b(chmod|chown)\b.*777/, desc: "dangerous permissions" },
+	{ pattern: /\bmkfs\b/, desc: "filesystem format" },
+	{ pattern: /\bdd\b.*\bof=\/dev\//, desc: "raw device write" },
+	{ pattern: />\s*\/dev\/sd[a-z]/, desc: "raw device overwrite" },
+	{ pattern: /\bkill\s+-9\s+-1\b/, desc: "kill all processes" },
+	{ pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, desc: "fork bomb" },
+	{ pattern: /\bnixos-rebuild\s+(switch|boot|test)/, desc: "direct nixos-rebuild (use make targets)" },
+	{ pattern: /\bhome-manager\s+switch\b/, desc: "direct home-manager switch (use make targets)" },
+];
+
+// ── Protected path patterns (hard block via bash) ─────────────
+
+const dangerousBashWrites = [
+	/>\s*\.env/,
+	/>\s*\.dev\.vars/,
+	/>\s*.*\.pem/,
+	/>\s*.*\.key/,
+	/tee\s+.*\.env/,
+	/tee\s+.*\.dev\.vars/,
+	/cp\s+.*\s+\.env/,
+	/mv\s+.*\s+\.env/,
+];
+
+// ── Protected paths for write/edit tools ──────────────────────
+
+const protectedPaths = [
+	{ pattern: /\.env($|\.(?!example))/, desc: "environment file" },
+	{ pattern: /\.dev\.vars($|\.[^/]+$)/, desc: "dev vars file" },
+	{ pattern: /node_modules\//, desc: "node_modules" },
+	{ pattern: /^\.git\/|\/\.git\//, desc: "git directory" },
+	{ pattern: /\.pem$|\.key$/, desc: "private key file" },
+	{ pattern: /id_rsa|id_ed25519|id_ecdsa/, desc: "SSH key" },
+	{ pattern: /\.ssh\//, desc: ".ssh directory" },
+	{ pattern: /secrets?\.(json|ya?ml|toml)$/i, desc: "secrets file" },
+	{ pattern: /credentials/i, desc: "credentials file" },
+];
+
+const softProtectedPaths = [
+	{ pattern: /package-lock\.json$/, desc: "package-lock.json" },
+	{ pattern: /yarn\.lock$/, desc: "yarn.lock" },
+	{ pattern: /pnpm-lock\.yaml$/, desc: "pnpm-lock.yaml" },
+];
+
+// ── Helpers ───────────────────────────────────────────────────
+
 function expandHome(path: string): string {
 	if (path.startsWith("~/")) {
 		return join(homedir(), path.slice(2));
@@ -58,21 +95,18 @@ function expandHome(path: string): string {
 	return path;
 }
 
-// Check if a path matches a pattern (supports * wildcards)
 function pathMatches(path: string, pattern: string): boolean {
 	const expandedPattern = expandHome(pattern);
-	// Convert glob pattern to regex
 	const regexPattern = expandedPattern
-		.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape special chars
-		.replace(/\*/g, ".*") // Convert * to .*
-		.replace(/\?/g, "."); // Convert ? to .
-	const regex = new RegExp(`^${regexPattern}`);
-	return regex.test(path);
+		.replace(/[.+^${}()|[\]\\]/g, "\\$&")
+		.replace(/\*/g, ".*")
+		.replace(/\?/g, ".");
+	return new RegExp(`^${regexPattern}`).test(path);
 }
 
-// Default policies (used if no config file exists)
+// ── Default path policies ─────────────────────────────────────
+
 const DEFAULT_POLICIES: PathPolicy[] = [
-	// AI unified storage policies
 	{
 		name: "ai-sessions",
 		description: "Session files should go to unified AI storage",
@@ -119,7 +153,6 @@ const DEFAULT_POLICIES: PathPolicy[] = [
 		action: "warn",
 		enabled: true,
 	},
-	// Security policies
 	{
 		name: "no-secrets-in-repos",
 		description: "Prevent writing secrets to git repositories",
@@ -139,47 +172,40 @@ const DEFAULT_POLICIES: PathPolicy[] = [
 			"*/nixpkgs/pkgs/",
 		],
 		action: "warn",
-		enabled: false, // Disabled by default, enable if you work on nixpkgs
+		enabled: false,
 	},
 ];
 
+// ── Extension entry point ─────────────────────────────────────
+
 export default function (pi: ExtensionAPI) {
 	const CONFIG_PATH = join(homedir(), ".config", "ai", "path-policies.json");
 	let policies: PathPolicy[] = DEFAULT_POLICIES;
 	let configLoaded = false;
 
-	// Load policies from config file
 	async function loadPolicies(): Promise<void> {
 		if (configLoaded) return;
-
 		try {
 			if (existsSync(CONFIG_PATH)) {
 				const content = await readFile(CONFIG_PATH, "utf-8");
 				const config: PolicyConfig = JSON.parse(content);
 				if (config.policies && Array.isArray(config.policies)) {
-					// Merge with defaults: config policies override defaults by name
 					const configPolicyNames = new Set(config.policies.map((p) => p.name));
-					const mergedPolicies = [
+					policies = [
 						...config.policies,
 						...DEFAULT_POLICIES.filter((p) => !configPolicyNames.has(p.name)),
 					];
-					policies = mergedPolicies;
 				}
 			}
 		} catch (error) {
-			// Use defaults on error
-			console.error(`[path-validator] Error loading config: ${error}`);
+			console.error(`[guardrails] Error loading config: ${error}`);
 		}
 		configLoaded = true;
 	}
 
-	// Check a file path against all policies
 	function validatePath(filePath: string): {
 		valid: boolean;
-		violations: Array<{
-			policy: PathPolicy;
-			reason: string;
-		}>;
+		violations: Array<{ policy: PathPolicy; reason: string }>;
 	} {
 		const expandedPath = expandHome(filePath);
 		const filename = expandedPath.split("/").pop() || "";
@@ -188,66 +214,44 @@ export default function (pi: ExtensionAPI) {
 		for (const policy of policies) {
 			if (policy.enabled === false) continue;
 
-			// Check filename pattern
 			let matchesPattern = false;
 			if (policy.filenamePattern) {
-				const regex = new RegExp(policy.filenamePattern, "i");
-				matchesPattern = regex.test(filename);
+				matchesPattern = new RegExp(policy.filenamePattern, "i").test(filename);
 			}
 			if (policy.pathPattern) {
-				const regex = new RegExp(policy.pathPattern, "i");
-				matchesPattern = matchesPattern || regex.test(expandedPath);
+				matchesPattern = matchesPattern || new RegExp(policy.pathPattern, "i").test(expandedPath);
 			}
 
 			if (!matchesPattern) continue;
 
-			// Check blocked paths
 			if (policy.blockedPaths) {
 				for (const blockedPath of policy.blockedPaths) {
 					if (pathMatches(expandedPath, blockedPath)) {
-						violations.push({
-							policy,
-							reason: `Path matches blocked pattern: ${blockedPath}`,
-						});
+						violations.push({ policy, reason: `Path matches blocked pattern: ${blockedPath}` });
 						break;
 					}
 				}
 			}
 
-			// Check allowed paths (if specified, path must match at least one)
 			if (policy.allowedPaths && policy.allowedPaths.length > 0) {
-				const isAllowed = policy.allowedPaths.some((allowedPath) =>
-					pathMatches(expandedPath, allowedPath)
-				);
+				const isAllowed = policy.allowedPaths.some((p) => pathMatches(expandedPath, p));
 				if (!isAllowed) {
-					violations.push({
-						policy,
-						reason: `Path not in allowed locations: ${policy.allowedPaths.join(", ")}`,
-					});
+					violations.push({ policy, reason: `Path not in allowed locations: ${policy.allowedPaths.join(", ")}` });
 				}
 			}
 
-			// Check required filename format
 			if (policy.requiredFilenameFormat) {
-				const formatRegex = new RegExp(policy.requiredFilenameFormat);
-				if (!formatRegex.test(filename)) {
+				if (!new RegExp(policy.requiredFilenameFormat).test(filename)) {
 					const formatDesc = policy.formatDescription || policy.requiredFilenameFormat;
 					const example = policy.formatExample ? ` (e.g., ${policy.formatExample})` : "";
-					violations.push({
-						policy,
-						reason: `Filename doesn't match required format: ${formatDesc}${example}`,
-					});
+					violations.push({ policy, reason: `Filename doesn't match required format: ${formatDesc}${example}` });
 				}
 			}
 		}
 
-		return {
-			valid: violations.length === 0,
-			violations,
-		};
+		return { valid: violations.length === 0, violations };
 	}
 
-	// Format violation message
 	function formatViolation(
 		violation: { policy: PathPolicy; reason: string },
 		filePath: string,
@@ -256,8 +260,7 @@ export default function (pi: ExtensionAPI) {
 		const lines: string[] = [];
 		const policy = violation.policy;
 		const action = policy.action || "warn";
-
-		const icon = action === "block" ? "🚫" : "⚠️";
+		const icon = action === "block" ? "🛑" : "⚠️";
 		const actionLabel = action === "block" ? "BLOCKED" : "WARNING";
 		const color = action === "block" ? "error" : "warning";
 
@@ -267,98 +270,128 @@ export default function (pi: ExtensionAPI) {
 		}
 		lines.push(theme.fg("dim", `   ${violation.reason}`));
 		lines.push(theme.fg("dim", `   Path: ${filePath}`));
-
 		if (policy.suggestedPath) {
 			lines.push(theme.fg("accent", `   → Suggested: ${policy.suggestedPath}`));
 		}
-
 		return lines;
 	}
 
-	// Hook into tool calls
+	// ── Tool call hook: bash commands + file writes ───────────
+
 	pi.on("tool_call", async (event, ctx) => {
 		await loadPolicies();
-
 		const toolName = event.toolName?.toLowerCase() || "";
 
-		// Only check file write operations
+		// ── Bash command security ──
+		if (toolName === "bash") {
+			const command = (event.input.command as string) || "";
+
+			// Check dangerous commands (confirm before allowing)
+			for (const { pattern, desc } of dangerousCommands) {
+				if (pattern.test(command)) {
+					if (!ctx.hasUI) {
+						return { block: true, reason: `Blocked ${desc} (no UI to confirm)` };
+					}
+					const ok = await ctx.ui.confirm(`⚠️ Dangerous command: ${desc}`, command);
+					if (!ok) {
+						return { block: true, reason: `Blocked ${desc} by user` };
+					}
+					break;
+				}
+			}
+
+			// Check bash writes to protected paths (hard block)
+			for (const pattern of dangerousBashWrites) {
+				if (pattern.test(command)) {
+					ctx.ui.notify(`🛑 Blocked bash write to protected path`, "warning");
+					return { block: true, reason: "Bash command writes to protected path" };
+				}
+			}
+
+			return undefined;
+		}
+
+		// ── Write/Edit path validation ──
 		if (!["write", "edit", "notebookedit"].includes(toolName)) {
 			return undefined;
 		}
 
-		// Extract file path from tool input
 		const input = event.input || {};
 		const filePath = input.file_path || input.filePath || input.path || input.notebook_path;
+		if (!filePath || typeof filePath !== "string") return undefined;
 
-		if (!filePath || typeof filePath !== "string") {
-			return undefined;
+		const normalizedPath = normalize(filePath);
+
+		// Check hard-protected paths (always block)
+		for (const { pattern, desc } of protectedPaths) {
+			if (pattern.test(normalizedPath)) {
+				ctx.ui.notify(`🛑 Blocked write to ${desc}: ${filePath}`, "warning");
+				return { block: true, reason: `Protected path: ${desc}` };
+			}
 		}
 
-		// Validate the path
-		const result = validatePath(filePath);
+		// Check soft-protected paths (confirm)
+		for (const { pattern, desc } of softProtectedPaths) {
+			if (pattern.test(normalizedPath)) {
+				if (!ctx.hasUI) {
+					return { block: true, reason: `Protected path (no UI): ${desc}` };
+				}
+				const ok = await ctx.ui.confirm(
+					`⚠️ Modifying ${desc}`,
+					`Are you sure you want to modify ${filePath}?`,
+				);
+				if (!ok) {
+					return { block: true, reason: `User blocked write to ${desc}` };
+				}
+				break;
+			}
+		}
 
+		// Check configurable path policies
+		const result = validatePath(filePath);
 		if (!result.valid) {
 			const theme = ctx.ui.theme;
-			const blockingViolations = result.violations.filter(
-				(v) => v.policy.action === "block"
-			);
-			const warningViolations = result.violations.filter(
-				(v) => v.policy.action !== "block"
-			);
+			const blockingViolations = result.violations.filter((v) => v.policy.action === "block");
+			const warningViolations = result.violations.filter((v) => v.policy.action !== "block");
 
-			// Show warnings via widget
 			if (warningViolations.length > 0) {
 				const widgetLines: string[] = [
 					theme.bold("Path Validation Warnings"),
 					theme.fg("dim", "─".repeat(50)),
 				];
-
 				for (const violation of warningViolations) {
 					widgetLines.push(...formatViolation(violation, filePath, theme));
 					widgetLines.push("");
 				}
-
-				ctx.ui.setWidget("path-validator", widgetLines);
-
-				// Auto-dismiss after 10 seconds
-				setTimeout(() => {
-					ctx.ui.setWidget("path-validator", undefined);
-				}, 10000);
+				ctx.ui.setWidget("guardrails", widgetLines);
+				setTimeout(() => ctx.ui.setWidget("guardrails", undefined), 10000);
 			}
 
-			// Block if any blocking violations
 			if (blockingViolations.length > 0) {
 				const messages = blockingViolations.map((v) => {
 					let msg = `[${v.policy.name}] ${v.reason}`;
-					if (v.policy.suggestedPath) {
-						msg += ` (use ${v.policy.suggestedPath} instead)`;
-					}
+					if (v.policy.suggestedPath) msg += ` (use ${v.policy.suggestedPath} instead)`;
 					return msg;
 				});
-
-				return {
-					block: true,
-					reason: messages.join("\n"),
-				};
+				return { block: true, reason: messages.join("\n") };
 			}
 		}
 
 		return undefined;
 	});
 
-	// Command to list policies
+	// ── Commands ──────────────────────────────────────────────
+
 	pi.registerCommand("path-policies", {
 		description: "List path policies. Usage: /path-policies [filter] [--verbose]",
 		handler: async (args, ctx) => {
 			await loadPolicies();
 			const theme = ctx.ui.theme;
 
-			// Parse arguments
 			const argStr = (args || "").trim();
 			const verbose = argStr.includes("--verbose") || argStr.includes("-v");
 			const filter = argStr.replace(/--verbose|-v/g, "").trim().toLowerCase();
 
-			// Filter policies
 			let filteredPolicies = policies;
 			if (filter) {
 				filteredPolicies = policies.filter((p) =>
@@ -373,7 +406,6 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			// Build output lines
 			const lines: string[] = [
 				theme.bold(`📋 Path Policies${filter ? ` (${filter})` : ""} [${filteredPolicies.length}/${policies.length}]`),
 				theme.fg("dim", "─".repeat(60)),
@@ -385,57 +417,32 @@ export default function (pi: ExtensionAPI) {
 					policy.action === "block" ? "error" : "warning",
 					policy.action || "warn"
 				);
-
-				// Compact: name + status + action on one line
 				lines.push(`${theme.fg("accent", "•")} ${theme.bold(policy.name)} [${status}] ${action}`);
 
 				if (verbose) {
-					// Verbose: show all details
-					if (policy.description) {
-						lines.push(`  ${theme.fg("dim", policy.description)}`);
-					}
-					if (policy.filenamePattern) {
-						lines.push(`  ${theme.fg("dim", `Filename: ${policy.filenamePattern}`)}`);
-					}
-					if (policy.pathPattern) {
-						lines.push(`  ${theme.fg("dim", `Path: ${policy.pathPattern}`)}`);
-					}
-					if (policy.requiredFilenameFormat) {
-						lines.push(`  ${theme.fg("dim", `Format: ${policy.formatDescription || policy.requiredFilenameFormat}`)}`);
-					}
-					if (policy.blockedPaths?.length) {
-						lines.push(`  ${theme.fg("dim", `Blocked: ${policy.blockedPaths.join(", ")}`)}`);
-					}
-					if (policy.suggestedPath) {
-						lines.push(`  ${theme.fg("accent", `→ ${policy.suggestedPath}`)}`);
-					}
+					if (policy.description) lines.push(`  ${theme.fg("dim", policy.description)}`);
+					if (policy.filenamePattern) lines.push(`  ${theme.fg("dim", `Filename: ${policy.filenamePattern}`)}`);
+					if (policy.pathPattern) lines.push(`  ${theme.fg("dim", `Path: ${policy.pathPattern}`)}`);
+					if (policy.requiredFilenameFormat) lines.push(`  ${theme.fg("dim", `Format: ${policy.formatDescription || policy.requiredFilenameFormat}`)}`);
+					if (policy.blockedPaths?.length) lines.push(`  ${theme.fg("dim", `Blocked: ${policy.blockedPaths.join(", ")}`)}`);
+					if (policy.suggestedPath) lines.push(`  ${theme.fg("accent", `→ ${policy.suggestedPath}`)}`);
 					lines.push("");
 				}
 			}
 
-			if (!verbose) {
-				lines.push(theme.fg("dim", "Use --verbose for details"));
-			}
+			if (!verbose) lines.push(theme.fg("dim", "Use --verbose for details"));
 			lines.push(theme.fg("dim", `Config: ${CONFIG_PATH}`));
 
-			// Write to temp file for full viewing
 			const tmpFile = `/tmp/path-policies-${Date.now()}.txt`;
-			// Strip ANSI codes for file
 			const plainLines = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, ""));
 			await writeFile(tmpFile, plainLines.join("\n"), "utf-8");
 
-			// Show in widget (may truncate) + notify about file
 			ctx.ui.setWidget("path-policies", lines);
 			ctx.ui.notify(`Full output: ${tmpFile}`, "info");
-
-			// Longer timeout for verbose mode
-			setTimeout(() => {
-				ctx.ui.setWidget("path-policies", undefined);
-			}, verbose ? 60000 : 20000);
+			setTimeout(() => ctx.ui.setWidget("path-policies", undefined), verbose ? 60000 : 20000);
 		},
 	});
 
-	// Command to reload policies
 	pi.registerCommand("reload-policies", {
 		description: "Reload path validation policies from config file",
 		handler: async (_args, ctx) => {
dots/pi/agent/extensions/path-validator/package.json → dots/pi/agent/extensions/guardrails/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "path-validator",
+  "name": "guardrails",
   "version": "1.0.0",
   "description": "Configurable file path validation for AI coding agents",
   "type": "module",