/** * exc-safety — Permission gate for exc CLI commands in pi * * Intercepts bash tool calls containing `exc` commands and enforces the same * safety model as the Excloud console agent backend: * - Read-only verbs (list, get, etc.) auto-approved * - --help invocations always allowed * - Mutating verbs require user confirmation (with risk level) * - Destructive verbs get a stronger warning * * Toggle modes via /exc-safety command: * - relaxed (default): auto-allow medium risk, confirm destructive only * - strict: confirm every mutating command * - off: pass everything through (no interception) * * State persists per session via pi.appendEntry(). * * Usage: * cp exc-safety.ts ~/.pi/agent/extensions/exc-safety.ts * pi -e ./exc-safety.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; // ─── Constants ─────────────────────────────────────────────────────────── /** * Read-only verbs sourced from the excloud-agent backend * (gen-cli-sdk/cli.json + CLI-only commands). * Any exc invocation whose final verb is in this set is auto-allowed. */ const READ_VERBS = new Set([ "list", "get", "capacity", "seriallogs", "metrics", "health", "localip", "me", "quota", "version", ]); /** * Destructive verbs that permanently destroy or deauthorise resources. * Mapped from excloud-agent `buildExcApproval()` plus a few CLI additions. */ const DESTRUCTIVE_VERBS = new Set([ "delete", "terminate", "release", "revoke", "disassociate", "unprotect", "remove", ]); type SafetyMode = "strict" | "relaxed" | "off"; interface SafetyState { mode: SafetyMode; } // ─── Command parsing ───────────────────────────────────────────────────── /** * True when a token looks like a shell operator, redirection, or * metacharacter rather than a command word. */ function isShellMeta(token: string): boolean { return /^[|&;<>]/.test(token) || /^\d*[>&]/.test(token); } /** * Extract subcommand path — non-flag, non-shell-meta tokens. * Stops at the first `-`-prefixed argument or shell metacharacter. * `exc compute list --id 5` → `["compute","list"]`. * `exc me 2>&1` → `["me"]`. */ function excSubcommandPath(args: string[]): string[] { const out: string[] = []; for (const a of args) { if (a.startsWith("-") || isShellMeta(a)) break; out.push(a); } return out; } /** * Return the final verb from the subcommand path, or "" if none. * `exc compute list` → `"list"`, `exc securitygroup rule delete` → `"delete"`. */ function excVerb(args: string[]): string { const path = excSubcommandPath(args); if (path.length === 0) return ""; return path[path.length - 1].toLowerCase(); } /** * True when any argument is exactly `--help` or `-h`. */ function isHelpInvocation(args: string[]): boolean { return args.some((a) => a === "--help" || a === "-h"); } /** * Parse `exc` from a bash command line. Handles `exc ...`, `sudo exc ...`, * `/usr/local/bin/exc ...`, etc. Returns argv after the exc token or null. */ function parseExcCommand(command: string): { args: string[]; fullCommand: string } | null { const trimmed = command.trim(); const tokens = shellSplit(trimmed); // Match bare `exc` or path-qualified (`/usr/local/bin/exc`, `./exc`). const excIdx = tokens.findIndex((t) => t === "exc" || t.endsWith("/exc")); if (excIdx === -1) return null; const args = tokens.slice(excIdx + 1); if (args.length === 0) return null; return { args, fullCommand: trimmed }; } /** * Naive shell tokenizer — splits on whitespace while respecting * single-quoted and double-quoted segments. Good enough for classification; * edge cases are acceptable misses (we operate on the safe side). */ function shellSplit(input: string): string[] { const tokens: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (inSingle) { if (ch === "'") { inSingle = false; } else { current += ch; } } else if (inDouble) { if (ch === '"') { inDouble = false; } else if (ch === "\\" && i + 1 < input.length) { current += input[++i]; } else { current += ch; } } else { if (ch === "'") { inSingle = true; } else if (ch === '"') { inDouble = true; } else if (ch === "\\" && i + 1 < input.length) { current += input[++i]; } else if (ch === " " || ch === "\t" || ch === "\n") { if (current.length > 0) { tokens.push(current); current = ""; } } else { current += ch; } } } if (current.length > 0) tokens.push(current); return tokens; } // ─── Extension ─────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let mode: SafetyMode = "relaxed"; let confirmationsBlocked = 0; function persist() { pi.appendEntry("exc-safety-config", { mode }); } // Restore saved mode from the current branch. pi.on("session_start", async (_event, ctx) => { for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === "exc-safety-config") { const data = entry.data as SafetyState | undefined; if (data?.mode) mode = data.mode; } } }); pi.on("session_tree", async (_event, ctx) => { for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === "exc-safety-config") { const data = entry.data as SafetyState | undefined; if (data?.mode) mode = data.mode; } } }); // ── Command gate ─────────────────────────────────────────────────── pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return; if (mode === "off") return; const command = (event.input as { command?: string }).command; if (!command) return; const parsed = parseExcCommand(command); if (!parsed) return; const { args, fullCommand } = parsed; const verb = excVerb(args); // Read-only verbs: auto-allow. if (verb && READ_VERBS.has(verb)) return; // --help / -h is always safe. if (isHelpInvocation(args)) return; // Unknown verb but exc is present → treat as mutate (conservative). const isDestructive = verb ? DESTRUCTIVE_VERBS.has(verb) : false; const riskLabel = isDestructive ? "⚠ DESTRUCTIVE" : "⚠ Mutating"; // In relaxed mode, only confirm destructive commands. if (mode === "relaxed" && !isDestructive) return; confirmationsBlocked++; const allowed = await ctx.ui.confirm( `${riskLabel} exc command`, [ `Command:\n ${fullCommand}`, "", isDestructive ? "This will permanently destroy or deauthorise resources. Resources will stop billing after cleanup." : "This will change live Excloud resources. Resources created or changed will continue billing until cleaned up.", "", `Safety mode: ${mode} (/exc-safety to change)`, ].join("\n"), ); if (!allowed) { return { block: true, reason: `Blocked by exc-safety (mode: ${mode}, risk: ${isDestructive ? "destructive" : "mutate"}). Use /exc-safety to adjust the safety level or re-issue with explicit user confirmation.`, }; } }); // ── /exc-safety command ──────────────────────────────────────────── pi.registerCommand("exc-safety", { description: "Set exc command safety level (relaxed | strict | off)", getArgumentCompletions: (prefix: string) => { const options = [ { value: "relaxed", label: "relaxed — confirm destructive only, auto-allow medium risk" }, { value: "strict", label: "strict — confirm every mutating exc command" }, { value: "off", label: "off — no interception (passthrough)" }, ]; const filtered = options.filter((o) => o.value.startsWith(prefix)); return filtered.length > 0 ? filtered.map((o) => ({ value: o.value, label: o.label })) : null; }, handler: async (args, ctx) => { const prev = mode; const input = args?.trim().toLowerCase(); if (input === "strict" || input === "relaxed" || input === "off") { mode = input; persist(); ctx.ui.notify(`exc-safety: ${prev} → ${mode}`, "info"); } else { ctx.ui.notify( `Usage: /exc-safety relaxed|strict|off (current: ${mode}, ${confirmationsBlocked} blocked this session)`, "warning", ); } }, }); }