main
  1#!/usr/bin/env bun
  2/**
  3 * PreToolUse hook (Write) — validate file paths and filenames.
  4 *
  5 * Mirrors pi's path-validator extension:
  6 *   • Reads policies from ~/.config/ai/path-policies.json
  7 *   • Blocks writes to wrong locations (old claude history paths)
  8 *   • Enforces filename conventions (YYYY-MM-DD-slug.md)
  9 *   • Falls back to built-in defaults if no config found
 10 */
 11
 12import { readStdinJSON, HOME } from "./lib.ts";
 13import { readFileSync, existsSync } from "node:fs";
 14import { join, basename } from "node:path";
 15
 16// ── Types ──────────────────────────────────────────────────────────
 17
 18interface PreToolInput {
 19  tool_name: string;
 20  tool_input: {
 21    file_path?: string;
 22    filePath?: string;
 23    path?: string;
 24    command?: string;
 25  };
 26}
 27
 28interface PathPolicy {
 29  name: string;
 30  description?: string;
 31  filenamePattern?: string;
 32  pathPattern?: string;
 33  allowedPaths?: string[];
 34  blockedPaths?: string[];
 35  requiredFilenameFormat?: string;
 36  formatDescription?: string;
 37  formatExample?: string;
 38  suggestedPath?: string;
 39  action?: "warn" | "block" | "suggest";
 40  enabled?: boolean;
 41}
 42
 43interface PolicyConfig {
 44  policies: PathPolicy[];
 45}
 46
 47// ── Helpers ────────────────────────────────────────────────────────
 48
 49function expandHome(p: string): string {
 50  return p.startsWith("~/") ? join(HOME, p.slice(2)) : p;
 51}
 52
 53function pathMatches(path: string, pattern: string): boolean {
 54  const expanded = expandHome(pattern)
 55    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
 56    .replace(/\*/g, ".*")
 57    .replace(/\?/g, ".");
 58  return new RegExp(`^${expanded}`).test(path);
 59}
 60
 61// ── Default policies (same as pi path-validator) ───────────────────
 62
 63const DEFAULT_POLICIES: PathPolicy[] = [
 64  {
 65    name: "ai-sessions-location",
 66    description: "Session files must go to unified AI storage, not old Claude paths",
 67    filenamePattern: ".*session.*\\.md$",
 68    blockedPaths: [
 69      "~/.config/claude/history/sessions/",
 70      "~/.claude/history/sessions/",
 71    ],
 72    suggestedPath: "~/.local/share/ai/sessions/YYYY-MM/",
 73    action: "block",
 74    enabled: true,
 75  },
 76  {
 77    name: "ai-sessions-format",
 78    description: "Session files must follow YYYY-MM-DD-description.md format",
 79    pathPattern: ".*/ai/sessions/.*\\.md$",
 80    requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
 81    formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only, no underscores)",
 82    formatExample: "2026-02-05-unified-ai-storage.md",
 83    action: "block",
 84    enabled: true,
 85  },
 86  {
 87    name: "ai-plans-location",
 88    description: "Plan files must go to unified AI storage",
 89    filenamePattern: ".*plan.*\\.md$",
 90    blockedPaths: [
 91      "~/.config/claude/plans/",
 92      "~/.claude/plans/",
 93      "~/.config/claude/history/plans/",
 94    ],
 95    suggestedPath: "~/.local/share/ai/plans/",
 96    action: "block",
 97    enabled: true,
 98  },
 99  {
100    name: "ai-plans-format",
101    description: "Plan files must follow kebab-case.md format (no dates)",
102    pathPattern: ".*/ai/plans/.*\\.md$",
103    requiredFilenameFormat: "^[a-z0-9][a-z0-9-]*\\.md$",
104    formatDescription: "kebab-case.md (lowercase, hyphens, no dates)",
105    formatExample: "homelab-migration-plan.md",
106    action: "block",
107    enabled: true,
108  },
109  {
110    name: "ai-learnings-location",
111    description: "Learning files must go to unified AI storage",
112    filenamePattern: ".*learning.*\\.md$",
113    blockedPaths: ["~/.config/claude/history/learnings/"],
114    suggestedPath: "~/.local/share/ai/learnings/YYYY-MM/",
115    action: "block",
116    enabled: true,
117  },
118  {
119    name: "ai-learnings-format",
120    description: "Learning files must follow YYYY-MM-DD-description.md format",
121    pathPattern: ".*/ai/learnings/.*\\.md$",
122    requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
123    formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
124    formatExample: "2026-02-05-nix-flake-patterns.md",
125    action: "block",
126    enabled: true,
127  },
128  {
129    name: "ai-research-location",
130    description: "Research files must go to unified AI storage",
131    filenamePattern: ".*research.*\\.md$",
132    blockedPaths: ["~/.config/claude/history/research/"],
133    suggestedPath: "~/.local/share/ai/research/YYYY-MM/",
134    action: "block",
135    enabled: true,
136  },
137  {
138    name: "ai-research-format",
139    description: "Research files must follow YYYY-MM-DD-description.md format",
140    pathPattern: ".*/ai/research/.*\\.md$",
141    requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
142    formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
143    formatExample: "2026-02-05-chrono-node-evaluation.md",
144    action: "block",
145    enabled: true,
146  },
147  {
148    name: "no-secrets-in-repos",
149    description: "Prevent writing secrets to git repositories",
150    filenamePattern: "\\.(env|pem|key|secret|credentials)$",
151    blockedPaths: ["~/src/*", "~/projects/*"],
152    action: "block",
153    enabled: true,
154  },
155];
156
157// ── Load policies ──────────────────────────────────────────────────
158
159function loadPolicies(): PathPolicy[] {
160  const configPath = join(HOME, ".config", "ai", "path-policies.json");
161  try {
162    if (existsSync(configPath)) {
163      const config: PolicyConfig = JSON.parse(
164        readFileSync(configPath, "utf-8")
165      );
166      if (config.policies && Array.isArray(config.policies)) {
167        const names = new Set(config.policies.map((p) => p.name));
168        return [
169          ...config.policies,
170          ...DEFAULT_POLICIES.filter((p) => !names.has(p.name)),
171        ];
172      }
173    }
174  } catch {}
175  return DEFAULT_POLICIES;
176}
177
178// ── Validate ───────────────────────────────────────────────────────
179
180function validate(
181  filePath: string,
182  policies: PathPolicy[]
183): { blocks: string[]; warns: string[] } {
184  const expanded = expandHome(filePath);
185  const fname = basename(expanded);
186  const blocks: string[] = [];
187  const warns: string[] = [];
188
189  for (const pol of policies) {
190    if (pol.enabled === false) continue;
191
192    // Check if this policy applies (filename or path pattern)
193    let matches = false;
194    if (pol.filenamePattern && new RegExp(pol.filenamePattern, "i").test(fname))
195      matches = true;
196    if (pol.pathPattern && new RegExp(pol.pathPattern, "i").test(expanded))
197      matches = true;
198    if (!matches) continue;
199
200    const action = pol.action || "warn";
201    const bucket = action === "block" ? blocks : warns;
202
203    // Check blocked paths
204    if (pol.blockedPaths) {
205      for (const bp of pol.blockedPaths) {
206        if (pathMatches(expanded, bp)) {
207          let msg = `[${pol.name}] ${pol.description || "Blocked path"}`;
208          if (pol.suggestedPath) msg += `\n   → Use: ${pol.suggestedPath}`;
209          bucket.push(msg);
210          break;
211        }
212      }
213    }
214
215    // Check allowed paths
216    if (pol.allowedPaths?.length) {
217      const ok = pol.allowedPaths.some((ap) => pathMatches(expanded, ap));
218      if (!ok) {
219        bucket.push(
220          `[${pol.name}] Path not in allowed locations: ${pol.allowedPaths.join(", ")}`
221        );
222      }
223    }
224
225    // Check required filename format
226    if (pol.requiredFilenameFormat) {
227      if (!new RegExp(pol.requiredFilenameFormat).test(fname)) {
228        const desc = pol.formatDescription || pol.requiredFilenameFormat;
229        const ex = pol.formatExample ? `\n   Example: ${pol.formatExample}` : "";
230        bucket.push(`[${pol.name}] Filename doesn't match format: ${desc}${ex}`);
231      }
232    }
233  }
234
235  return { blocks, warns };
236}
237
238// ── Main ───────────────────────────────────────────────────────────
239
240async function main() {
241  const data = await readStdinJSON<PreToolInput>();
242  if (!data) process.exit(0);
243
244  const filePath =
245    data.tool_input?.file_path ||
246    data.tool_input?.filePath ||
247    data.tool_input?.path;
248
249  if (!filePath || typeof filePath !== "string") process.exit(0);
250
251  const policies = loadPolicies();
252  const { blocks, warns } = validate(filePath, policies);
253
254  // Stderr warnings (non-blocking)
255  for (const w of warns) {
256    process.stderr.write(`⚠️  ${w}\n`);
257  }
258
259  // Block if violations
260  if (blocks.length > 0) {
261    process.stdout.write(
262      JSON.stringify({
263        decision: "block",
264        reason: ["BLOCKED: Path policy violation", "", ...blocks].join("\n"),
265      })
266    );
267  }
268
269  process.exit(0);
270}
271
272main();