Commit e3f0e3bc77e2
Changed files (4)
dots
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"