288 lines
8.7 KiB
TypeScript
288 lines
8.7 KiB
TypeScript
/**
|
|
* 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<SafetyState>("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",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
|
|
}
|