main
1/**
2 * Deployment Guard Extension - Protect production deployments
3 *
4 * Prevents accidental deployments to production hosts by requiring confirmation
5 * for dangerous operations like `make switch`, `make host/<hostname>/switch`, etc.
6 *
7 * Features:
8 * - Detects deployment commands (make switch, make boot, nixos-rebuild)
9 * - Identifies production hosts from globals.nix
10 * - Requires user confirmation before deployment
11 * - Shows git status to ensure clean state
12 * - Suggests dry-build first if not already run
13 */
14
15import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
17
18// Production hosts that require extra confirmation
19const PRODUCTION_HOSTS = [
20 "rhea", // NixOS server
21 "atlas", // VPS
22];
23
24// Commands that trigger deployment
25const DEPLOYMENT_PATTERNS = [
26 /make\s+switch/,
27 /make\s+boot/,
28 /make\s+host\/[^/]+\/(switch|boot)/,
29 /nixos-rebuild\s+(switch|boot)/,
30];
31
32export default function (pi: ExtensionAPI) {
33 pi.on("tool_call", async (event, ctx) => {
34 if (!isToolCallEventType("bash", event)) return;
35
36 const command = event.input.command;
37
38 // Check if this is a deployment command
39 const isDeployment = DEPLOYMENT_PATTERNS.some((pattern) => pattern.test(command));
40 if (!isDeployment) return;
41
42 // Extract host from command if specified
43 const hostMatch = command.match(/make\s+host\/([^/]+)\/(switch|boot)/);
44 const targetHost = hostMatch ? hostMatch[1] : "current";
45
46 // Check if targeting production
47 const isProduction = PRODUCTION_HOSTS.some((host) =>
48 targetHost === host || (targetHost === "current" && command.includes(host))
49 );
50
51 // Get git status
52 const gitStatus = await pi.exec("git", ["status", "--porcelain"], {
53 cwd: ctx.cwd,
54 timeout: 5
55 });
56
57 const isDirty = gitStatus.stdout.trim().length > 0;
58 const hasUncommitted = isDirty;
59
60 // Build warning message
61 let warningLines = [
62 `⚠️ Deployment detected: ${command}`,
63 ` Target: ${targetHost}`,
64 ];
65
66 if (isProduction) {
67 warningLines.push(` 🔴 PRODUCTION HOST`);
68 }
69
70 if (hasUncommitted) {
71 warningLines.push(` ⚠️ Uncommitted changes detected`);
72 }
73
74 // Show git status if dirty
75 if (isDirty) {
76 warningLines.push("");
77 warningLines.push("Git status:");
78 gitStatus.stdout.split("\n").slice(0, 10).forEach((line) => {
79 if (line.trim()) warningLines.push(` ${line}`);
80 });
81 }
82
83 // Suggest dry-build if not already run
84 if (!command.includes("dry-build") && !command.includes("build")) {
85 warningLines.push("");
86 warningLines.push("💡 Consider running dry-build first:");
87 if (hostMatch) {
88 warningLines.push(` make host/${hostMatch[1]}/dry-build`);
89 } else {
90 warningLines.push(` make dry-build`);
91 }
92 }
93
94 // Show warning
95 ctx.ui.notify(warningLines.join("\n"), "warning");
96
97 // Require confirmation for production
98 if (isProduction) {
99 const confirmed = await ctx.ui.confirm(
100 "Deploy to Production?",
101 `This will deploy to ${targetHost}. Continue?`
102 );
103
104 if (!confirmed) {
105 return { block: true, reason: "Production deployment cancelled by user" };
106 }
107 } else {
108 // For non-production, just confirm if there are uncommitted changes
109 if (hasUncommitted) {
110 const confirmed = await ctx.ui.confirm(
111 "Deploy with uncommitted changes?",
112 "You have uncommitted changes. Deploy anyway?"
113 );
114
115 if (!confirmed) {
116 return { block: true, reason: "Deployment cancelled due to uncommitted changes" };
117 }
118 }
119 }
120
121 // Log the deployment
122 ctx.ui.setStatus("deployment", ctx.ui.theme.fg("warning", `🚀 Deploying to ${targetHost}...`));
123 });
124
125 pi.on("tool_result", async (event, ctx) => {
126 // Clear deployment status after tool completes
127 if (event.toolName === "bash" &&
128 DEPLOYMENT_PATTERNS.some((p) => p.test(event.input.command))) {
129 ctx.ui.setStatus("deployment", undefined);
130 }
131 });
132}