Files
exc-safety/exc-safety.ts
2026-05-07 22:38:24 +05:30

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 "@earendil-works/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",
);
}
},
});
}