Commit 46de761541b0
Changed files (7)
.pi
home
common
dev
.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/