Commit b46ef27eeb60
Changed files (2)
dots
pi
agent
extensions
guardrails
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",