Commit e3f0e3bc77e2

Vincent Demeester <vincent@sbr.pm>
2026-02-05 17:00:21
feat(pi): add path-validator extension for file write policies
Configurable path validation for AI coding agents to ensure files are written to correct locations based on defined policies. - Added path-validator extension with warn/block actions - Policies defined in ~/.config/ai/path-policies.json - Default policies for AI unified storage (sessions, plans, learnings) - Security policy to block secrets in git repositories - Commands: /path-policies to list, /reload-policies to refresh - Currently pi-only, Claude Code support planned Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 512f2af
Changed files (4)
dots
config
pi
agent
extensions
dots/config/ai/path-policies.json
@@ -0,0 +1,72 @@
+{
+  "policies": [
+    {
+      "name": "ai-sessions",
+      "description": "Session files should go to unified AI storage",
+      "filenamePattern": ".*session.*\\.md$",
+      "blockedPaths": [
+        "~/.config/claude/history/sessions/",
+        "~/.claude/history/sessions/"
+      ],
+      "suggestedPath": "~/.local/share/ai/sessions/",
+      "action": "warn",
+      "enabled": true
+    },
+    {
+      "name": "ai-plans",
+      "description": "Plan files should go to unified AI storage",
+      "filenamePattern": ".*plan.*\\.md$",
+      "blockedPaths": [
+        "~/.config/claude/plans/",
+        "~/.claude/plans/"
+      ],
+      "suggestedPath": "~/.local/share/ai/plans/",
+      "action": "warn",
+      "enabled": true
+    },
+    {
+      "name": "ai-learnings",
+      "description": "Learning files should go to unified AI storage",
+      "filenamePattern": ".*learning.*\\.md$",
+      "blockedPaths": [
+        "~/.config/claude/history/learnings/"
+      ],
+      "suggestedPath": "~/.local/share/ai/learnings/",
+      "action": "warn",
+      "enabled": true
+    },
+    {
+      "name": "ai-research",
+      "description": "Research files should go to unified AI storage",
+      "filenamePattern": ".*research.*\\.md$",
+      "blockedPaths": [
+        "~/.config/claude/history/research/"
+      ],
+      "suggestedPath": "~/.local/share/ai/research/",
+      "action": "warn",
+      "enabled": true
+    },
+    {
+      "name": "no-secrets-in-repos",
+      "description": "Prevent writing secrets to git repositories",
+      "filenamePattern": "\\.(env|pem|key|secret|credentials)$",
+      "blockedPaths": [
+        "~/src/*",
+        "~/projects/*"
+      ],
+      "action": "block",
+      "enabled": true
+    },
+    {
+      "name": "org-files-location",
+      "description": "Org files should go to ~/desktop/org/",
+      "filenamePattern": "\\.org$",
+      "allowedPaths": [
+        "~/desktop/org/*",
+        "~/src/*/docs/*"
+      ],
+      "action": "warn",
+      "enabled": false
+    }
+  ]
+}
dots/pi/agent/extensions/path-validator/index.ts
@@ -0,0 +1,381 @@
+/**
+ * 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)
+ * - 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
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { existsSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { homedir } from "node:os";
+
+// Policy definition
+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[];
+	// Suggested correct path (for redirects)
+	suggestedPath?: string;
+	// Action: "warn" (default), "block", or "suggest"
+	action?: "warn" | "block" | "suggest";
+	// Whether this policy is enabled
+	enabled?: boolean;
+}
+
+interface PolicyConfig {
+	policies: PathPolicy[];
+}
+
+// Expand ~ to home directory
+function expandHome(path: string): string {
+	if (path.startsWith("~/")) {
+		return join(homedir(), path.slice(2));
+	}
+	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);
+}
+
+// Default policies (used if no config file exists)
+const DEFAULT_POLICIES: PathPolicy[] = [
+	// AI unified storage policies
+	{
+		name: "ai-sessions",
+		description: "Session files should go to unified AI storage",
+		filenamePattern: ".*session.*\\.md$",
+		blockedPaths: [
+			"~/.config/claude/history/sessions/",
+			"~/.claude/history/sessions/",
+		],
+		suggestedPath: "~/.local/share/ai/sessions/",
+		action: "warn",
+		enabled: true,
+	},
+	{
+		name: "ai-plans",
+		description: "Plan files should go to unified AI storage",
+		filenamePattern: ".*plan.*\\.md$",
+		blockedPaths: [
+			"~/.config/claude/plans/",
+			"~/.claude/plans/",
+		],
+		suggestedPath: "~/.local/share/ai/plans/",
+		action: "warn",
+		enabled: true,
+	},
+	{
+		name: "ai-learnings",
+		description: "Learning files should go to unified AI storage",
+		filenamePattern: ".*learning.*\\.md$",
+		blockedPaths: [
+			"~/.config/claude/history/learnings/",
+		],
+		suggestedPath: "~/.local/share/ai/learnings/",
+		action: "warn",
+		enabled: true,
+	},
+	{
+		name: "ai-research",
+		description: "Research files should go to unified AI storage",
+		filenamePattern: ".*research.*\\.md$",
+		blockedPaths: [
+			"~/.config/claude/history/research/",
+		],
+		suggestedPath: "~/.local/share/ai/research/",
+		action: "warn",
+		enabled: true,
+	},
+	// Security policies
+	{
+		name: "no-secrets-in-repos",
+		description: "Prevent writing secrets to git repositories",
+		filenamePattern: "\\.(env|pem|key|secret|credentials)$",
+		blockedPaths: [
+			"~/src/*",
+			"~/projects/*",
+		],
+		action: "block",
+		enabled: true,
+	},
+	{
+		name: "protect-nixpkgs",
+		description: "Warn when writing outside standard locations in nixpkgs",
+		pathPattern: ".*/nixpkgs/(?!pkgs/).*",
+		allowedPaths: [
+			"*/nixpkgs/pkgs/",
+		],
+		action: "warn",
+		enabled: false, // Disabled by default, enable if you work on nixpkgs
+	},
+];
+
+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 = [
+						...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}`);
+		}
+		configLoaded = true;
+	}
+
+	// Check a file path against all policies
+	function validatePath(filePath: string): {
+		valid: boolean;
+		violations: Array<{
+			policy: PathPolicy;
+			reason: string;
+		}>;
+	} {
+		const expandedPath = expandHome(filePath);
+		const filename = expandedPath.split("/").pop() || "";
+		const violations: Array<{ policy: PathPolicy; reason: string }> = [];
+
+		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);
+			}
+			if (policy.pathPattern) {
+				const regex = new RegExp(policy.pathPattern, "i");
+				matchesPattern = matchesPattern || regex.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}`,
+						});
+						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)
+				);
+				if (!isAllowed) {
+					violations.push({
+						policy,
+						reason: `Path not in allowed locations: ${policy.allowedPaths.join(", ")}`,
+					});
+				}
+			}
+		}
+
+		return {
+			valid: violations.length === 0,
+			violations,
+		};
+	}
+
+	// Format violation message
+	function formatViolation(
+		violation: { policy: PathPolicy; reason: string },
+		filePath: string,
+		theme: any
+	): string[] {
+		const lines: string[] = [];
+		const policy = violation.policy;
+		const action = policy.action || "warn";
+
+		const icon = action === "block" ? "๐Ÿšซ" : "โš ๏ธ";
+		const actionLabel = action === "block" ? "BLOCKED" : "WARNING";
+		const color = action === "block" ? "error" : "warning";
+
+		lines.push(theme.fg(color, `${icon} ${actionLabel}: ${policy.name}`));
+		if (policy.description) {
+			lines.push(theme.fg("dim", `   ${policy.description}`));
+		}
+		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
+	pi.on("tool_call", async (event, ctx) => {
+		await loadPolicies();
+
+		const toolName = event.toolName?.toLowerCase() || "";
+
+		// Only check file write operations
+		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;
+		}
+
+		// Validate the path
+		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"
+			);
+
+			// 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);
+			}
+
+			// 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)`;
+					}
+					return msg;
+				});
+
+				return {
+					block: true,
+					reason: messages.join("\n"),
+				};
+			}
+		}
+
+		return undefined;
+	});
+
+	// Command to list policies
+	pi.registerCommand("path-policies", {
+		description: "List configured path validation policies",
+		handler: async (_args, ctx) => {
+			await loadPolicies();
+			const theme = ctx.ui.theme;
+
+			const widgetLines: string[] = [
+				theme.bold("๐Ÿ“‹ Path Validation Policies"),
+				theme.fg("dim", "โ”€".repeat(50)),
+			];
+
+			for (const policy of policies) {
+				const status = policy.enabled === false ? theme.fg("dim", "[disabled]") : theme.fg("success", "[enabled]");
+				const action = theme.fg(
+					policy.action === "block" ? "error" : "warning",
+					policy.action || "warn"
+				);
+
+				widgetLines.push(`${theme.fg("accent", "โ€ข")} ${theme.bold(policy.name)} ${status} ${action}`);
+				if (policy.description) {
+					widgetLines.push(`  ${theme.fg("dim", policy.description)}`);
+				}
+				if (policy.filenamePattern) {
+					widgetLines.push(`  ${theme.fg("dim", `Pattern: ${policy.filenamePattern}`)}`);
+				}
+				if (policy.suggestedPath) {
+					widgetLines.push(`  ${theme.fg("dim", `Suggest: ${policy.suggestedPath}`)}`);
+				}
+				widgetLines.push("");
+			}
+
+			widgetLines.push(theme.fg("dim", `Config: ${CONFIG_PATH}`));
+
+			ctx.ui.setWidget("path-policies", widgetLines);
+
+			setTimeout(() => {
+				ctx.ui.setWidget("path-policies", undefined);
+			}, 20000);
+		},
+	});
+
+	// Command to reload policies
+	pi.registerCommand("reload-policies", {
+		description: "Reload path validation policies from config file",
+		handler: async (_args, ctx) => {
+			configLoaded = false;
+			await loadPolicies();
+			ctx.ui.notify(`Loaded ${policies.length} path policies`, "success");
+		},
+	});
+}
dots/pi/agent/extensions/path-validator/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "path-validator",
+  "version": "1.0.0",
+  "description": "Configurable file path validation for AI coding agents",
+  "type": "module",
+  "pi": {
+    "extensions": ["./index.ts"]
+  }
+}
dots/Makefile
@@ -61,7 +61,7 @@ opencode-plugin : ~/.config/opencode/plugin
 pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md
 agent-skills : ~/.config/agent-skills
 agent-skill-manager-bin : ~/bin/agent-skill-manager
-ai-config : ~/.config/ai/skills
+ai-config : ~/.config/ai/skills ~/.config/ai/path-policies.json
 
 # Agent skill manager tool
 ~/bin/agent-skill-manager : $(dotfiles)/config/agent-skills/agent-skill-manager force
@@ -100,6 +100,12 @@ ai-config : ~/.config/ai/skills
 	@mkdir -p ~/.config/ai
 	@ln -snf ~/.config/claude/skills ~/.config/ai/skills
 
+# Path validation policies for AI agents
+~/.config/ai/path-policies.json : $(dotfiles)/config/ai/path-policies.json force
+	@echo "๐Ÿ“‹ Linking $(dotfiles)/config/ai/path-policies.json -> ~/.config/ai/path-policies.json"
+	@mkdir -p ~/.config/ai
+	@ln -snf $(dotfiles)/config/ai/path-policies.json ~/.config/ai/path-policies.json
+
 # Generate ntfy client.yml from template with passage secrets injected
 ~/.config/ntfy/client.yml : $(dotfiles)/config/ntfy/client.yml.in $(dotfiles)/config/ntfy/ntfy-update-config force
 	@echo "โš™๏ธ  Generating $$@ from template with passage secrets"