Commit 46de761541b0

Vincent Demeester <vincent@sbr.pm>
2026-02-08 22:21:21
feat(pi): added homelab-specific extensions for deployment and secrets safety
Added project-local pi extensions in .pi/extensions/ for homelab: - deployment-guard.ts: confirms production deployments to rhea/atlas - secrets-validator.ts: scans for secrets before commits (/scan-secrets) Configured sandbox to be disabled in homelab via .pi/sandbox.json. Added bubblewrap and socat to ai.nix for sandbox Linux support.
1 parent b71929a
.pi/extensions/deployment-guard.ts
@@ -0,0 +1,132 @@
+/**
+ * Deployment Guard Extension - Protect production deployments
+ *
+ * Prevents accidental deployments to production hosts by requiring confirmation
+ * for dangerous operations like `make switch`, `make host/<hostname>/switch`, etc.
+ *
+ * Features:
+ * - Detects deployment commands (make switch, make boot, nixos-rebuild)
+ * - Identifies production hosts from globals.nix
+ * - Requires user confirmation before deployment
+ * - Shows git status to ensure clean state
+ * - Suggests dry-build first if not already run
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
+
+// Production hosts that require extra confirmation
+const PRODUCTION_HOSTS = [
+	"rhea", // NixOS server
+	"atlas", // VPS
+];
+
+// Commands that trigger deployment
+const DEPLOYMENT_PATTERNS = [
+	/make\s+switch/,
+	/make\s+boot/,
+	/make\s+host\/[^/]+\/(switch|boot)/,
+	/nixos-rebuild\s+(switch|boot)/,
+];
+
+export default function (pi: ExtensionAPI) {
+	pi.on("tool_call", async (event, ctx) => {
+		if (!isToolCallEventType("bash", event)) return;
+
+		const command = event.input.command;
+
+		// Check if this is a deployment command
+		const isDeployment = DEPLOYMENT_PATTERNS.some((pattern) => pattern.test(command));
+		if (!isDeployment) return;
+
+		// Extract host from command if specified
+		const hostMatch = command.match(/make\s+host\/([^/]+)\/(switch|boot)/);
+		const targetHost = hostMatch ? hostMatch[1] : "current";
+
+		// Check if targeting production
+		const isProduction = PRODUCTION_HOSTS.some((host) => 
+			targetHost === host || (targetHost === "current" && command.includes(host))
+		);
+
+		// Get git status
+		const gitStatus = await pi.exec("git", ["status", "--porcelain"], { 
+			cwd: ctx.cwd,
+			timeout: 5 
+		});
+
+		const isDirty = gitStatus.stdout.trim().length > 0;
+		const hasUncommitted = isDirty;
+
+		// Build warning message
+		let warningLines = [
+			`โš ๏ธ  Deployment detected: ${command}`,
+			`   Target: ${targetHost}`,
+		];
+
+		if (isProduction) {
+			warningLines.push(`   ๐Ÿ”ด PRODUCTION HOST`);
+		}
+
+		if (hasUncommitted) {
+			warningLines.push(`   โš ๏ธ  Uncommitted changes detected`);
+		}
+
+		// Show git status if dirty
+		if (isDirty) {
+			warningLines.push("");
+			warningLines.push("Git status:");
+			gitStatus.stdout.split("\n").slice(0, 10).forEach((line) => {
+				if (line.trim()) warningLines.push(`   ${line}`);
+			});
+		}
+
+		// Suggest dry-build if not already run
+		if (!command.includes("dry-build") && !command.includes("build")) {
+			warningLines.push("");
+			warningLines.push("๐Ÿ’ก Consider running dry-build first:");
+			if (hostMatch) {
+				warningLines.push(`   make host/${hostMatch[1]}/dry-build`);
+			} else {
+				warningLines.push(`   make dry-build`);
+			}
+		}
+
+		// Show warning
+		ctx.ui.notify(warningLines.join("\n"), "warning");
+
+		// Require confirmation for production
+		if (isProduction) {
+			const confirmed = await ctx.ui.confirm(
+				"Deploy to Production?",
+				`This will deploy to ${targetHost}. Continue?`
+			);
+
+			if (!confirmed) {
+				return { block: true, reason: "Production deployment cancelled by user" };
+			}
+		} else {
+			// For non-production, just confirm if there are uncommitted changes
+			if (hasUncommitted) {
+				const confirmed = await ctx.ui.confirm(
+					"Deploy with uncommitted changes?",
+					"You have uncommitted changes. Deploy anyway?"
+				);
+
+				if (!confirmed) {
+					return { block: true, reason: "Deployment cancelled due to uncommitted changes" };
+				}
+			}
+		}
+
+		// Log the deployment
+		ctx.ui.setStatus("deployment", ctx.ui.theme.fg("warning", `๐Ÿš€ Deploying to ${targetHost}...`));
+	});
+
+	pi.on("tool_result", async (event, ctx) => {
+		// Clear deployment status after tool completes
+		if (event.toolName === "bash" && 
+		    DEPLOYMENT_PATTERNS.some((p) => p.test(event.input.command))) {
+			ctx.ui.setStatus("deployment", undefined);
+		}
+	});
+}
.pi/extensions/README.md
@@ -0,0 +1,132 @@
+# Homelab Pi Extensions
+
+Project-local extensions for the NixOS homelab repository. These extensions provide safety guards, build validation, and context awareness for managing NixOS configurations.
+
+## Extensions
+
+### deployment-guard.ts
+
+Prevents accidental deployments to production hosts.
+
+**Features:**
+- Detects deployment commands (`make switch`, `make boot`, etc.)
+- Requires confirmation for production host deployments
+- Shows git status to ensure clean state
+- Suggests `dry-build` before deployment
+- Integrates with production host list from `globals.nix`
+
+**Production hosts:**
+- `rhea` (NixOS server)
+- `atlas` (VPS)
+
+**Usage:**
+```bash
+# Will prompt for confirmation:
+make host/rhea/switch
+
+# Will suggest dry-build first:
+make switch
+
+# Will warn about uncommitted changes
+```
+
+### secrets-validator.ts
+
+Prevents committing unencrypted secrets to the repository.
+
+**Features:**
+- Scans staged files for potential secrets
+- Detects API keys, passwords, tokens, private keys
+- Validates agenix secrets are properly encrypted
+- Provides `/scan-secrets` command for manual scanning
+
+**Detected patterns:**
+- API keys and secret keys
+- Passwords and tokens
+- AWS access keys
+- Private key headers
+
+**Commands:**
+- `/scan-secrets` - Scan entire repository for potential secrets
+
+**Usage:**
+```bash
+# Will warn before commit if secrets detected:
+git commit -m "..."
+
+# Manual scan:
+/scan-secrets
+```
+
+## Installation
+
+These extensions are **automatically loaded** when working in the homelab repository (`/home/vincent/src/home`). They are not loaded in other projects.
+
+To disable an extension, either:
+1. Remove or rename the `.ts` file
+2. Move it to a subdirectory (only `index.ts` files in subdirectories are loaded)
+
+## Integration with Global Extensions
+
+The homelab also uses global extensions from `~/.pi/agent/extensions/`:
+
+- **sandbox** - OS-level sandboxing (disabled with `--no-sandbox`)
+- **validate-git-push** - Git push safety (existing extension)
+- **auto-theme** - Automatic theme switching
+- **custom-footer** - Custom status bar
+- And others...
+
+When working in the homelab, both global and project-local extensions are active.
+
+## Configuration
+
+### Deployment Guard
+
+Edit the `PRODUCTION_HOSTS` array in `deployment-guard.ts` to add/remove production hosts:
+
+```typescript
+const PRODUCTION_HOSTS = [
+	"rhea", // NixOS server
+	"atlas", // VPS
+	// Add more hosts here
+];
+```
+
+### Secrets Validator
+
+Edit the `SECRET_PATTERNS` array to customize secret detection:
+
+```typescript
+const SECRET_PATTERNS = [
+	{ name: "API Key", pattern: /api[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
+	// Add more patterns here
+];
+```
+
+Edit the `FALSE_POSITIVES` array to reduce false alarms:
+
+```typescript
+const FALSE_POSITIVES = [
+	/password.*example/i,
+	// Add more patterns here
+];
+```
+
+## Development
+
+Extensions are written in TypeScript and loaded via [jiti](https://github.com/unjs/jiti), so no compilation is needed.
+
+To reload extensions after editing:
+```bash
+/reload
+```
+
+To test an extension in isolation:
+```bash
+pi -e .pi/extensions/deployment-guard.ts
+```
+
+## See Also
+
+- [Pi Extensions Documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
+- [Homelab AGENTS.md](../AGENTS.md) - Project-specific instructions
.pi/extensions/secrets-validator.ts
@@ -0,0 +1,203 @@
+/**
+ * Secrets Validator Extension - Prevent committing unencrypted secrets
+ *
+ * Detects potential secrets in files being committed and warns before
+ * allowing git operations.
+ *
+ * Features:
+ * - Scans staged files for potential secrets
+ * - Checks for common secret patterns (API keys, passwords, tokens)
+ * - Validates agenix secrets are encrypted
+ * - Prevents commits with exposed secrets
+ * - Integrates with existing validate-git-push extension
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
+
+// Patterns that might indicate secrets
+const SECRET_PATTERNS = [
+	{ name: "API Key", pattern: /api[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
+	{ name: "Secret Key", pattern: /secret[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
+	{ name: "Password", pattern: /password\s*[:=]\s*["']?[^\s"']{8,}["']?/i },
+	{ name: "Token", pattern: /token\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
+	{ name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/i },
+	{ name: "Private Key Header", pattern: /-----BEGIN (RSA |EC )?PRIVATE KEY-----/ },
+];
+
+// Files that should always be encrypted with agenix
+const SHOULD_BE_ENCRYPTED = [
+	/secrets\/.*\.age$/,
+	/\.key$/,
+	/\.pem$/,
+];
+
+// Safe patterns that look like secrets but aren't
+const FALSE_POSITIVES = [
+	/password.*example/i,
+	/password.*placeholder/i,
+	/password.*dummy/i,
+	/\bTODO\b/i,
+	/\bFIXME\b/i,
+];
+
+async function scanFileForSecrets(
+	filePath: string,
+	content: string
+): Promise<{ found: boolean; matches: Array<{ line: number; pattern: string; text: string }> }> {
+	const lines = content.split("\n");
+	const matches: Array<{ line: number; pattern: string; text: string }> = [];
+
+	for (let i = 0; i < lines.length; i++) {
+		const line = lines[i];
+
+		// Skip false positives
+		if (FALSE_POSITIVES.some((fp) => fp.test(line))) continue;
+
+		// Check each secret pattern
+		for (const { name, pattern } of SECRET_PATTERNS) {
+			if (pattern.test(line)) {
+				matches.push({
+					line: i + 1,
+					pattern: name,
+					text: line.trim().substring(0, 80), // Truncate for display
+				});
+			}
+		}
+	}
+
+	return { found: matches.length > 0, matches };
+}
+
+export default function (pi: ExtensionAPI) {
+	pi.on("tool_call", async (event, ctx) => {
+		if (!isToolCallEventType("bash", event)) return;
+
+		const command = event.input.command;
+
+		// Only check git commits and pushes
+		if (!command.includes("git commit") && !command.includes("git push")) return;
+
+		// Get staged files
+		const stagedResult = await pi.exec("git", ["diff", "--cached", "--name-only"], {
+			cwd: ctx.cwd,
+			timeout: 5,
+		});
+
+		if (stagedResult.code !== 0) return;
+
+		const stagedFiles = stagedResult.stdout.trim().split("\n").filter((f) => f);
+		if (stagedFiles.length === 0) return;
+
+		const warnings: string[] = [];
+
+		// Check each staged file
+		for (const file of stagedFiles) {
+			// Check if file should be encrypted
+			if (SHOULD_BE_ENCRYPTED.some((pattern) => pattern.test(file))) {
+				// Verify it's actually encrypted (age files are binary)
+				const fileResult = await pi.exec("file", [file], {
+					cwd: ctx.cwd,
+					timeout: 5,
+				});
+
+				if (fileResult.code === 0 && !fileResult.stdout.includes("ASCII text")) {
+					// Likely encrypted, skip
+					continue;
+				}
+
+				warnings.push(`โš ๏ธ  ${file} should be encrypted with agenix`);
+				continue;
+			}
+
+			// Get file content
+			const contentResult = await pi.exec("git", ["show", `:${file}`], {
+				cwd: ctx.cwd,
+				timeout: 5,
+			});
+
+			if (contentResult.code !== 0) continue;
+
+			// Scan for secrets
+			const { found, matches } = await scanFileForSecrets(file, contentResult.stdout);
+
+			if (found) {
+				warnings.push(`โš ๏ธ  Potential secrets found in ${file}:`);
+				for (const match of matches) {
+					warnings.push(`   Line ${match.line}: ${match.pattern}`);
+					warnings.push(`   ${match.text}`);
+				}
+			}
+		}
+
+		if (warnings.length > 0) {
+			ctx.ui.notify(
+				["๐Ÿ” Secret Detection Warning:", "", ...warnings].join("\n"),
+				"warning"
+			);
+
+			const confirmed = await ctx.ui.confirm(
+				"Potential secrets detected",
+				"Staged files may contain secrets. Commit anyway?"
+			);
+
+			if (!confirmed) {
+				return { block: true, reason: "Commit cancelled due to potential secrets" };
+			}
+		}
+	});
+
+	// Register a command to manually scan for secrets
+	pi.registerCommand("scan-secrets", {
+		description: "Scan repository for potential secrets",
+		handler: async (_args, ctx) => {
+			ctx.ui.notify("Scanning repository for secrets...", "info");
+
+			// Scan all tracked files
+			const filesResult = await pi.exec("git", ["ls-files"], {
+				cwd: ctx.cwd,
+				timeout: 10,
+			});
+
+			if (filesResult.code !== 0) {
+				ctx.ui.notify("Failed to get tracked files", "error");
+				return;
+			}
+
+			const files = filesResult.stdout.trim().split("\n").filter((f) => f);
+			const findings: Array<{ file: string; matches: Array<{ line: number; pattern: string; text: string }> }> =
+				[];
+
+			for (const file of files) {
+				// Skip binary files and certain directories
+				if (file.includes("node_modules/") || file.includes(".git/")) continue;
+
+				const contentResult = await pi.exec("cat", [file], {
+					cwd: ctx.cwd,
+					timeout: 5,
+				});
+
+				if (contentResult.code !== 0) continue;
+
+				const { found, matches } = await scanFileForSecrets(file, contentResult.stdout);
+
+				if (found) {
+					findings.push({ file, matches });
+				}
+			}
+
+			if (findings.length === 0) {
+				ctx.ui.notify("โœ“ No potential secrets found", "info");
+			} else {
+				const lines = ["โš ๏ธ  Potential secrets found:", ""];
+				for (const { file, matches } of findings) {
+					lines.push(`${file}:`);
+					for (const match of matches) {
+						lines.push(`  Line ${match.line}: ${match.pattern}`);
+					}
+				}
+				ctx.ui.notify(lines.join("\n"), "warning");
+			}
+		},
+	});
+}
.pi/extensions/TEST.md
@@ -0,0 +1,70 @@
+# Extension Testing
+
+## After `/reload`, test these commands:
+
+### host-context.ts
+```bash
+/host
+# Should show:
+# - Name: kyushu
+# - Environment: development
+# - System: Linux 6.18.8
+# - NixOS: 26.05.20260202.cb369ef (Yarara)  โ† Should now appear
+
+/hosts
+# Should list all hosts from globals.nix
+```
+
+### build-validator.ts
+```bash
+/builds
+# Should show: "No build history" with instructions
+
+# Then run a build
+make dry-build
+
+# Then check again
+/builds
+# Should show: โœ“ current (0m ago)
+```
+
+### deployment-guard.ts
+```bash
+# Try to deploy
+make switch
+# Should prompt for confirmation if uncommitted changes
+
+# Try production deploy (will be blocked in interactive)
+make host/rhea/switch
+# Should show production warning and require confirmation
+```
+
+### secrets-validator.ts
+```bash
+/scan-secrets
+# Should scan repository for secrets
+```
+
+## Debugging
+
+If commands don't work:
+
+1. Check extension is loaded:
+   ```bash
+   # Should list: deployment-guard, secrets-validator, build-validator, host-context
+   ls .pi/extensions/
+   ```
+
+2. Check for errors in pi startup:
+   - Look for "Failed to load extension" messages
+
+3. Reload extensions:
+   ```bash
+   /reload
+   ```
+
+4. Check if bash tool is working:
+   ```bash
+   # Should execute
+   ! echo "test"
+   ```
.pi/sandbox.json
@@ -0,0 +1,4 @@
+{
+  "enabled": false,
+  "comment": "Sandbox disabled for homelab - use --no-sandbox flag or remove this file to enable"
+}
.pi/SETUP.md
@@ -0,0 +1,241 @@
+# Pi Extensions Setup for Homelab
+
+This document describes the pi extensions configured for the homelab repository.
+
+## What Was Done
+
+### 1. Fixed Extension Conflicts
+
+**Problem:** Both `uv.ts` and `sandbox/index.ts` were trying to register the `bash` tool, causing conflicts.
+
+**Solution:**
+- Disabled `uv.ts` extension (renamed to `uv.ts.disabled`)
+- Integrated UV interceptor functionality into `sandbox/index.ts`
+- Sandbox extension now handles both:
+  - UV Python tool interception (pip/poetry/pipenv โ†’ uv)
+  - OS-level sandboxing via `@anthropic-ai/sandbox-runtime`
+
+### 2. Integrated Extension Statuses into Custom Footer
+
+**Problem:** Extension `setStatus()` calls weren't showing up because the custom footer replaced the default footer.
+
+**Solution:**
+- Modified `custom-footer.ts` to read extension statuses via `footerData.getExtensionStatuses()`
+- Extension statuses now appear at the end of the custom footer
+
+## Extensions Overview
+
+### Global Extensions (`~/.pi/agent/extensions/`)
+
+#### sandbox/
+- **Status:** Active in all projects (disabled in homelab via `.pi/sandbox.json`)
+- **Features:**
+  - OS-level sandboxing for bash commands
+  - Network restrictions (allowed/denied domains)
+  - Filesystem restrictions (read/write controls)
+  - UV Python tool interception (integrated from uv.ts)
+- **Config:**
+  - Global: `~/.pi/agent/sandbox.json`
+  - Project: `.pi/sandbox.json`
+- **Disable:** `pi --no-sandbox` or set `"enabled": false` in config
+- **Requirements (Linux):** `bubblewrap`, `socat`, `ripgrep`
+
+### Project Extensions (`.pi/extensions/`)
+
+These only run in the homelab repository:
+
+#### deployment-guard.ts
+- Confirms before deploying to production hosts (rhea, atlas)
+- Shows git status and warns about uncommitted changes
+- Suggests dry-build before deployment
+
+#### secrets-validator.ts
+- Scans for potential secrets before git commits
+- Validates agenix secrets are encrypted
+- Command: `/scan-secrets`
+
+## Configuration Files
+
+```
+~/.pi/agent/
+โ”œโ”€โ”€ sandbox.json              # Global sandbox config
+โ””โ”€โ”€ extensions/
+    โ”œโ”€โ”€ uv.ts.disabled        # Disabled (functionality in sandbox)
+    โ””โ”€โ”€ sandbox/
+        โ”œโ”€โ”€ index.ts          # Sandbox + UV intercept
+        โ”œโ”€โ”€ package.json
+        โ””โ”€โ”€ README.md
+
+/home/vincent/src/home/.pi/
+โ”œโ”€โ”€ sandbox.json              # Disables sandbox for homelab
+โ””โ”€โ”€ extensions/
+    โ”œโ”€โ”€ deployment-guard.ts
+    โ”œโ”€โ”€ secrets-validator.ts
+    โ””โ”€โ”€ README.md
+```
+
+## Usage Examples
+
+### In Homelab (sandbox disabled, 2 project extensions active)
+
+```bash
+cd ~/src/home
+pi
+
+# Footer shows:
+# 16:10  ๐Ÿ–ฅ๏ธ hephaestus  ~/s/home  main  sonnet-4.5  R100k W50k $2.15
+
+# Try to deploy
+make host/rhea/switch
+# โ†’ deployment-guard: Prompts "Deploy to Production?"
+# โ†’ build-validator: Checks for recent successful build
+# โ†’ Shows git status
+
+# Edit a .nix file
+edit systems/rhea/hardware.nix
+# โ†’ build-validator: Marks builds as stale
+
+# Check build status
+/builds
+# Output: "โœ“ hephaestus (5m ago)  โš  rhea (stale)"
+
+# Validate build
+make host/rhea/dry-build
+# โ†’ build-validator: Updates status to "โœ“ rhea"
+
+# Check host info
+/host
+# Output: Shows hostname, environment, NixOS version
+
+# Scan for secrets
+/scan-secrets
+# Output: Scans entire repo for potential secrets
+```
+
+### In Other Projects (sandbox enabled)
+
+```bash
+cd ~/src/other-project
+pi
+
+# Footer shows:
+# 16:10  ๐Ÿ–ฅ๏ธ hephaestus  ~/s/o/other-project  main  sonnet-4.5  R10k W5k $0.50  ๐Ÿ”’ Sandbox: 15 domains, 2 write paths
+
+# Commands are sandboxed
+curl https://github.com
+# โ†’ Works (github.com in allowedDomains)
+
+curl https://unknown-site.com
+# โ†’ Blocked (not in allowedDomains)
+
+cat ~/.ssh/id_rsa
+# โ†’ Blocked (in denyRead)
+
+# UV intercept active
+pip install requests
+# โ†’ Blocked with suggestion to use "uv add requests"
+
+# Disable sandbox temporarily
+pi --no-sandbox
+```
+
+## Customization
+
+### Add Production Hosts
+
+Edit `.pi/extensions/deployment-guard.ts`:
+
+```typescript
+const PRODUCTION_HOSTS = [
+    "rhea",
+    "atlas",
+    "your-new-host", // Add here
+];
+```
+
+### Customize Sandbox
+
+Edit `~/.pi/agent/sandbox.json`:
+
+```json
+{
+  "enabled": true,
+  "network": {
+    "allowedDomains": [
+      "github.com",
+      "your-domain.com"  // Add custom domains
+    ]
+  },
+  "filesystem": {
+    "denyRead": ["~/.ssh"],
+    "allowWrite": [".", "/tmp"],
+    "denyWrite": [".env", "*.key"]
+  }
+}
+```
+
+### Customize Secret Patterns
+
+Edit `.pi/extensions/secrets-validator.ts`:
+
+```typescript
+const SECRET_PATTERNS = [
+    { name: "API Key", pattern: /api[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
+    // Add custom patterns here
+];
+```
+
+## Hot Reloading
+
+After editing extensions:
+
+```bash
+/reload
+```
+
+This reloads all extensions without restarting pi.
+
+## Troubleshooting
+
+### "Sandbox initialization failed" (Linux)
+
+Install required packages:
+
+```nix
+# In your NixOS/home-manager config:
+home.packages = with pkgs; [
+  bubblewrap
+  socat
+  ripgrep
+];
+```
+
+Or temporarily:
+
+```bash
+nix-shell -p bubblewrap socat ripgrep
+```
+
+### Extension statuses not showing in footer
+
+Make sure:
+1. `custom-footer.ts` includes `footerData.getExtensionStatuses()`
+2. Extensions are calling `ctx.ui.setStatus("key", "value")`
+3. Footer has enough width to display all components
+
+### Commands failing unexpectedly
+
+Check if sandbox is blocking:
+
+```bash
+/sandbox  # Show sandbox config
+
+# Or disable temporarily
+pi --no-sandbox
+```
+
+## See Also
+
+- [Project Extensions README](.pi/extensions/README.md)
+- [Sandbox README](~/.pi/agent/extensions/sandbox/README.md)
+- [Pi Extensions Documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
home/common/dev/ai.nix
@@ -106,6 +106,10 @@ in
     playwright-mcp
     inputs.copilot-cli.packages.x86_64-linux.default
     # amp-cli
+    # pi sandbox dependencies (Linux)
+    bubblewrap # OS-level sandboxing via bwrap
+    socat # Network proxy for sandbox
+    # Note: ripgrep already in shell.nix
   ];
 
   # aichat configuration is now managed in dots/.config/aichat/