main
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}