Commit 0bfe4ad254d9

Vincent Demeester <vincent@sbr.pm>
2026-02-16 10:07:09
feat: add nix-guardrails pi extension
Added approval guardrails for dangerous Nix commands intercepted via bash tool calls. Blocks direct nixos-rebuild and home-manager switch (enforcing make targets), and requires confirmation for nix eval, flake updates, store operations, and more. Rules are configurable via ~/.config/ai/nix-guardrails.json.
1 parent 214c6c6
Changed files (3)
dots
pi
agent
extensions
dots/pi/agent/extensions/nix-guardrails/index.ts
@@ -0,0 +1,377 @@
+/**
+ * Nix Guardrails Extension for Pi
+ *
+ * Requires approval for potentially dangerous or impactful Nix commands:
+ * - nix eval (can execute arbitrary code)
+ * - nix-build (builds derivations)
+ * - nixos-rebuild (direct system changes - should use make targets)
+ * - home-manager switch (direct home changes - should use make targets)
+ * - nix store operations (store modifications)
+ * - nix-channel operations (channel management)
+ * - nix-collect-garbage (garbage collection)
+ * - nix flake update (flake lock updates)
+ * - nix copy/push (store transfers)
+ * - nix develop/shell (enters new shell environments)
+ *
+ * Configuration: ~/.config/ai/nix-guardrails.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 { homedir } from "node:os";
+
+// ── Policy types ──────────────────────────────────────────────
+
+interface NixCommandRule {
+	pattern: RegExp;
+	description: string;
+	action: "confirm" | "block" | "allow";
+	reason: string;
+	enabled: boolean;
+	// If true, show the full command in confirmation
+	showCommand?: boolean;
+	// Suggested alternative (e.g., "use make switch instead")
+	suggestion?: string;
+}
+
+interface NixGuardrailsConfig {
+	rules: Array<{
+		pattern: string;
+		description: string;
+		action: "confirm" | "block" | "allow";
+		reason: string;
+		enabled?: boolean;
+		showCommand?: boolean;
+		suggestion?: string;
+	}>;
+}
+
+// ── Default Nix command rules ─────────────────────────────────
+
+const DEFAULT_RULES: NixCommandRule[] = [
+	{
+		pattern: /\bnix\s+eval\b/,
+		description: "nix eval (arbitrary code execution)",
+		action: "confirm",
+		reason: "nix eval can execute arbitrary Nix code and has side effects",
+		enabled: true,
+		showCommand: true,
+	},
+	{
+		pattern: /\bnix-build\b/,
+		description: "nix-build (builds derivations)",
+		action: "confirm",
+		reason: "nix-build will build derivations and consume resources",
+		enabled: true,
+		showCommand: false,
+	},
+	{
+		pattern: /\bnixos-rebuild\s+(switch|boot|test)\b/,
+		description: "direct nixos-rebuild",
+		action: "block",
+		reason: "Direct nixos-rebuild bypasses make targets and safety checks",
+		enabled: true,
+		suggestion: "Use 'make switch', 'make boot', or 'make host/<hostname>/switch' instead",
+	},
+	{
+		pattern: /\bhome-manager\s+switch\b/,
+		description: "direct home-manager switch",
+		action: "block",
+		reason: "Direct home-manager switch bypasses make targets",
+		enabled: true,
+		suggestion: "Use 'make switch' or appropriate make target instead",
+	},
+	{
+		pattern: /\bnix\s+store\s+(delete|gc|optimise)\b/,
+		description: "nix store modification",
+		action: "confirm",
+		reason: "Store operations can delete or modify nix store contents",
+		enabled: true,
+		showCommand: true,
+	},
+	{
+		pattern: /\bnix-collect-garbage\b/,
+		description: "nix garbage collection",
+		action: "confirm",
+		reason: "Garbage collection will delete unreferenced store paths",
+		enabled: true,
+		showCommand: true,
+	},
+	{
+		pattern: /\bnix-channel\s+(--add|--remove|--update)\b/,
+		description: "nix-channel modification",
+		action: "confirm",
+		reason: "Channel operations modify available package sources",
+		enabled: true,
+		showCommand: false,
+	},
+	{
+		pattern: /\bnix\s+flake\s+update\b/,
+		description: "nix flake update",
+		action: "confirm",
+		reason: "Flake updates will modify flake.lock and change input versions",
+		enabled: true,
+		showCommand: false,
+	},
+	{
+		pattern: /\bnix\s+flake\s+lock\b/,
+		description: "nix flake lock",
+		action: "confirm",
+		reason: "Flake lock will modify flake.lock",
+		enabled: true,
+		showCommand: false,
+	},
+	{
+		pattern: /\bnix\s+(copy|push)\b/,
+		description: "nix store transfer",
+		action: "confirm",
+		reason: "Store transfers can push/copy potentially large store paths",
+		enabled: true,
+		showCommand: true,
+	},
+	{
+		pattern: /\bnix\s+(develop|shell)\b/,
+		description: "nix development shell",
+		action: "confirm",
+		reason: "Entering a nix shell changes the environment context",
+		enabled: true,
+		showCommand: false,
+	},
+	{
+		pattern: /\bnix\s+run\b/,
+		description: "nix run (execute package)",
+		action: "confirm",
+		reason: "nix run executes packages directly",
+		enabled: true,
+		showCommand: true,
+	},
+	{
+		pattern: /\bnix-env\s+-[ie]/,
+		description: "nix-env package installation",
+		action: "confirm",
+		reason: "nix-env modifies user environment (prefer declarative config)",
+		enabled: true,
+		showCommand: true,
+		suggestion: "Consider using home-manager or system configuration instead",
+	},
+	{
+		pattern: /\bnix\s+profile\s+(install|remove|upgrade)\b/,
+		description: "nix profile modification",
+		action: "confirm",
+		reason: "Profile operations modify user profile (prefer declarative config)",
+		enabled: true,
+		showCommand: true,
+		suggestion: "Consider using home-manager or system configuration instead",
+	},
+];
+
+// ── Extension entry point ─────────────────────────────────────
+
+export default function (pi: ExtensionAPI) {
+	const CONFIG_PATH = join(homedir(), ".config", "ai", "nix-guardrails.json");
+	let rules: NixCommandRule[] = DEFAULT_RULES;
+	let configLoaded = false;
+
+	async function loadConfig(): Promise<void> {
+		if (configLoaded) return;
+		try {
+			if (existsSync(CONFIG_PATH)) {
+				const content = await readFile(CONFIG_PATH, "utf-8");
+				const config: NixGuardrailsConfig = JSON.parse(content);
+				if (config.rules && Array.isArray(config.rules)) {
+					rules = config.rules.map((r) => ({
+						pattern: new RegExp(r.pattern),
+						description: r.description,
+						action: r.action,
+						reason: r.reason,
+						enabled: r.enabled !== false,
+						showCommand: r.showCommand,
+						suggestion: r.suggestion,
+					}));
+				}
+			}
+		} catch (error) {
+			console.error(`[nix-guardrails] Error loading config: ${error}`);
+		}
+		configLoaded = true;
+	}
+
+	// ── Save default config ───────────────────────────────────
+
+	async function saveDefaultConfig(): Promise<void> {
+		try {
+			const configDir = join(homedir(), ".config", "ai");
+			const { mkdir } = await import("node:fs/promises");
+			await mkdir(configDir, { recursive: true });
+
+			const config: NixGuardrailsConfig = {
+				rules: DEFAULT_RULES.map((r) => ({
+					pattern: r.pattern.source,
+					description: r.description,
+					action: r.action,
+					reason: r.reason,
+					enabled: r.enabled,
+					showCommand: r.showCommand,
+					suggestion: r.suggestion,
+				})),
+			};
+
+			await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
+		} catch (error) {
+			console.error(`[nix-guardrails] Error saving config: ${error}`);
+		}
+	}
+
+	// ── Tool call hook: intercept bash commands ──────────────
+
+	pi.on("tool_call", async (event, ctx) => {
+		await loadConfig();
+
+		if (event.toolName?.toLowerCase() !== "bash") {
+			return undefined;
+		}
+
+		const command = (event.input.command as string) || "";
+
+		// Check each rule
+		for (const rule of rules) {
+			if (!rule.enabled) continue;
+
+			if (rule.pattern.test(command)) {
+				const theme = ctx.ui.theme;
+
+				// Handle different actions
+				if (rule.action === "block") {
+					const message = [
+						theme.fg("error", `🚫 BLOCKED: ${rule.description}`),
+						theme.fg("dim", `   Reason: ${rule.reason}`),
+					];
+					if (rule.suggestion) {
+						message.push(theme.fg("accent", `   💡 ${rule.suggestion}`));
+					}
+					message.push(theme.fg("dim", `   Command: ${command}`));
+
+					ctx.ui.notify(message.join("\n"), "error");
+					return {
+						block: true,
+						reason: `${rule.description}: ${rule.reason}${
+							rule.suggestion ? `\n${rule.suggestion}` : ""
+						}`,
+					};
+				}
+
+				if (rule.action === "confirm") {
+					if (!ctx.hasUI) {
+						return {
+							block: true,
+							reason: `${rule.description} requires approval (no UI available)`,
+						};
+					}
+
+					const confirmMessage = [
+						rule.reason,
+						rule.suggestion ? `\n💡 ${rule.suggestion}` : "",
+					]
+						.filter(Boolean)
+						.join("");
+
+					const displayCommand = rule.showCommand !== false ? command : undefined;
+
+					const ok = await ctx.ui.confirm(`⚠️ ${rule.description}`, confirmMessage, displayCommand);
+
+					if (!ok) {
+						return {
+							block: true,
+							reason: `${rule.description} blocked by user`,
+						};
+					}
+					// User approved, continue
+					break;
+				}
+
+				// rule.action === "allow" - no-op, but prevents later rules from matching
+				if (rule.action === "allow") {
+					break;
+				}
+			}
+		}
+
+		return undefined;
+	});
+
+	// ── Commands ──────────────────────────────────────────────
+
+	pi.registerCommand("nix-rules", {
+		description: "List Nix guardrail rules. Usage: /nix-rules [filter]",
+		handler: async (args, ctx) => {
+			await loadConfig();
+			const theme = ctx.ui.theme;
+
+			const filter = (args || "").trim().toLowerCase();
+			let filteredRules = rules;
+			if (filter) {
+				filteredRules = rules.filter(
+					(r) =>
+						r.description.toLowerCase().includes(filter) ||
+						r.action.toLowerCase() === filter ||
+						r.pattern.source.toLowerCase().includes(filter)
+				);
+			}
+
+			if (filteredRules.length === 0) {
+				ctx.ui.notify(`No rules matching "${filter}"`, "info");
+				return;
+			}
+
+			const lines: string[] = [
+				theme.bold(`🔒 Nix Guardrail Rules${filter ? ` (${filter})` : ""} [${filteredRules.length}/${rules.length}]`),
+				theme.fg("dim", "─".repeat(70)),
+			];
+
+			for (const rule of filteredRules) {
+				const status = rule.enabled ? theme.fg("success", "on") : theme.fg("dim", "off");
+				const actionColor = rule.action === "block" ? "error" : rule.action === "confirm" ? "warning" : "success";
+				const action = theme.fg(actionColor, rule.action.padEnd(7));
+				const icon = rule.action === "block" ? "🚫" : rule.action === "confirm" ? "⚠️" : "✅";
+
+				lines.push(`${icon} ${action} [${status}] ${theme.bold(rule.description)}`);
+				lines.push(`  ${theme.fg("dim", `Pattern: ${rule.pattern.source}`)}`);
+				lines.push(`  ${theme.fg("dim", `Reason: ${rule.reason}`)}`);
+				if (rule.suggestion) {
+					lines.push(`  ${theme.fg("accent", `💡 ${rule.suggestion}`)}`);
+				}
+				lines.push("");
+			}
+
+			lines.push(theme.fg("dim", `Config: ${CONFIG_PATH}`));
+			lines.push(theme.fg("dim", "Use /nix-rules-save to save default config"));
+
+			const tmpFile = `/tmp/nix-rules-${Date.now()}.txt`;
+			const plainLines = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, ""));
+			await writeFile(tmpFile, plainLines.join("\n"), "utf-8");
+
+			ctx.ui.setWidget("nix-rules", lines);
+			ctx.ui.notify(`Full output: ${tmpFile}`, "info");
+			setTimeout(() => ctx.ui.setWidget("nix-rules", undefined), 30000);
+		},
+	});
+
+	pi.registerCommand("nix-rules-reload", {
+		description: "Reload Nix guardrail rules from config file",
+		handler: async (_args, ctx) => {
+			configLoaded = false;
+			await loadConfig();
+			ctx.ui.notify(`Loaded ${rules.length} Nix guardrail rules`, "success");
+		},
+	});
+
+	pi.registerCommand("nix-rules-save", {
+		description: "Save default Nix guardrail rules to config file",
+		handler: async (_args, ctx) => {
+			await saveDefaultConfig();
+			ctx.ui.notify(`Saved default config to ${CONFIG_PATH}`, "success");
+		},
+	});
+}
dots/pi/agent/extensions/nix-guardrails/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "nix-guardrails",
+  "version": "1.0.0",
+  "description": "Nix command guardrails for pi-coding-agent",
+  "type": "module",
+  "pi": {
+    "extensions": ["./index.ts"]
+  }
+}
dots/pi/agent/extensions/nix-guardrails/README.md
@@ -0,0 +1,181 @@
+# Nix Guardrails Extension
+
+Pi extension that requires approval for potentially dangerous or impactful Nix commands.
+
+## Features
+
+### Protected Commands
+
+The extension intercepts and requires approval for:
+
+- **`nix eval`** - Can execute arbitrary Nix code with side effects
+- **`nix-build`** - Builds derivations and consumes resources
+- **`nixos-rebuild`** - Direct system changes (BLOCKED - use make targets instead)
+- **`home-manager switch`** - Direct home changes (BLOCKED - use make targets instead)
+- **`nix store` operations** - Store modifications, deletion, optimization
+- **`nix-collect-garbage`** - Deletes unreferenced store paths
+- **`nix-channel`** - Modifies package sources
+- **`nix flake update/lock`** - Modifies flake.lock file
+- **`nix copy/push`** - Store transfers (potentially large)
+- **`nix develop/shell`** - Changes environment context
+- **`nix run`** - Executes packages directly
+- **`nix-env`** - User environment modifications (suggests declarative alternatives)
+- **`nix profile`** - Profile modifications (suggests declarative alternatives)
+
+### Action Types
+
+- **confirm** - Shows a confirmation dialog before executing
+- **block** - Prevents execution entirely with suggested alternative
+- **allow** - Explicitly allows (for overriding other rules)
+
+## Configuration
+
+The extension can be configured via `~/.config/ai/nix-guardrails.json`:
+
+```json
+{
+  "rules": [
+    {
+      "pattern": "\\bnix\\s+eval\\b",
+      "description": "nix eval (arbitrary code execution)",
+      "action": "confirm",
+      "reason": "nix eval can execute arbitrary Nix code and has side effects",
+      "enabled": true,
+      "showCommand": true
+    },
+    {
+      "pattern": "\\bnixos-rebuild\\s+(switch|boot|test)\\b",
+      "description": "direct nixos-rebuild",
+      "action": "block",
+      "reason": "Direct nixos-rebuild bypasses make targets and safety checks",
+      "enabled": true,
+      "suggestion": "Use 'make switch', 'make boot', or 'make host/<hostname>/switch' instead"
+    }
+  ]
+}
+```
+
+### Configuration Fields
+
+- **pattern** - Regular expression to match commands
+- **description** - Human-readable description
+- **action** - `"confirm"`, `"block"`, or `"allow"`
+- **reason** - Why this rule exists
+- **enabled** - Whether the rule is active (default: `true`)
+- **showCommand** - Show full command in confirmation (default: `false` for confirm, `true` for block)
+- **suggestion** - Alternative command/approach to suggest
+
+## Commands
+
+### `/nix-rules [filter]`
+
+List all Nix guardrail rules. Optional filter by description, action, or pattern.
+
+```
+/nix-rules              # List all rules
+/nix-rules eval         # Filter by "eval"
+/nix-rules block        # Show only blocking rules
+```
+
+### `/nix-rules-reload`
+
+Reload configuration from `~/.config/ai/nix-guardrails.json`.
+
+### `/nix-rules-save`
+
+Save the default rule configuration to `~/.config/ai/nix-guardrails.json`. Useful for creating an initial config file to customize.
+
+## Usage Examples
+
+### Example 1: nix eval (confirmation)
+
+When Pi tries to run `nix eval .#nixosConfigurations.rhea.config.networking.hostName`:
+
+```
+⚠️ nix eval (arbitrary code execution)
+
+nix eval can execute arbitrary Nix code and has side effects
+
+Command: nix eval .#nixosConfigurations.rhea.config.networking.hostName
+
+[Approve] [Reject]
+```
+
+### Example 2: nixos-rebuild (blocked)
+
+When Pi tries to run `nixos-rebuild switch`:
+
+```
+🚫 BLOCKED: direct nixos-rebuild
+   Reason: Direct nixos-rebuild bypasses make targets and safety checks
+   💡 Use 'make switch', 'make boot', or 'make host/<hostname>/switch' instead
+   Command: nixos-rebuild switch
+```
+
+The command is blocked entirely and not executed.
+
+### Example 3: nix flake update (confirmation)
+
+When Pi tries to run `nix flake update`:
+
+```
+⚠️ nix flake update
+
+Flake updates will modify flake.lock and change input versions
+
+[Approve] [Reject]
+```
+
+## Customization
+
+To customize rules:
+
+1. Run `/nix-rules-save` to create the default config
+2. Edit `~/.config/ai/nix-guardrails.json`
+3. Modify, add, or remove rules
+4. Run `/nix-rules-reload` to apply changes
+
+Example customizations:
+
+```json
+{
+  "rules": [
+    // Disable nix eval confirmation
+    {
+      "pattern": "\\bnix\\s+eval\\b",
+      "description": "nix eval",
+      "action": "allow",
+      "reason": "Allow nix eval without confirmation",
+      "enabled": true
+    },
+    // Add custom rule for nix build
+    {
+      "pattern": "\\bnix\\s+build.*--impure",
+      "description": "impure nix build",
+      "action": "block",
+      "reason": "Impure builds should not be used in this project",
+      "enabled": true,
+      "suggestion": "Remove --impure flag"
+    }
+  ]
+}
+```
+
+## Integration with Homelab Repository
+
+This extension is particularly useful for the homelab repository where:
+
+- `nixos-rebuild` and `home-manager` should always go through make targets for safety
+- `nix eval` is occasionally needed for debugging but should be explicit
+- Flake updates should be intentional and approved
+- Store operations can have significant impact
+
+## Development
+
+The extension uses Pi's `tool_call` event hook to intercept bash commands before execution. It checks each command against configured rules and either:
+
+1. Blocks execution (returns `{ block: true, reason: "..." }`)
+2. Shows confirmation dialog (uses `ctx.ui.confirm()`)
+3. Allows execution (returns `undefined`)
+
+Pattern matching is done with JavaScript RegExp, and rules are processed in order (first match wins).