Commit 0bfe4ad254d9
Changed files (3)
dots
pi
agent
extensions
nix-guardrails
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).