From 248cff41d368a3160a5df83cd3300044b160b1a1 Mon Sep 17 00:00:00 2001 From: lolwierd Date: Mon, 27 Apr 2026 11:50:41 +0530 Subject: [PATCH] init --- .gitignore | 3 + LICENSE | 21 ++++ README.md | 42 ++++++++ exc-safety.ts | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++++ 5 files changed, 371 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 exc-safety.ts create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..900df8c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Excloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..663dd92 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# exc-safety + +**Permission gate for `exc` CLI commands in [pi](https://pi.dev).** + +The same safety model that powers the [Excloud](https://excloud.in) console agent, now running locally in your terminal. Every `exc` command the LLM tries to run is classified and gated before it ever hits the wire. + +## How it works + +| Classification | Behavior | +|---|---| +| **Read-only** (`list`, `get`, `capacity`, `seriallogs`, `metrics`, `health`, `localip`, `me`, `quota`, `version`) | ✅ Runs immediately | +| **`--help` / `-h`** | ✅ Always allowed — never hits the API | +| **Mutating** (`create`, `update`, `exec`, `scp`, `resize`, …) | ⚠️ Confirmed in `strict` mode; auto-allowed in `relaxed` | +| **Destructive** (`delete`, `terminate`, `release`, `revoke`, `disassociate`, `unprotect`, `remove`) | 🔴 Always confirmed | + +The allowlist is the same set used by the Excloud agent backend — union of every read-only verb from the live OpenAPI surface plus CLI-only commands. + +## Safety modes + +Toggle at any time with `/exc-safety`: + +| Mode | Behavior | +|---|---| +| `relaxed` *(default)* | Only destructive commands require confirmation | +| `strict` | Every mutating command requires confirmation | +| `off` | No interception — all commands pass through | + +## Install as a pi package + +```bash +pi install git:github.com/excloud-in/exc-safety +``` + +Or project-local (committed with your team's `.pi/settings.json`): + +```bash +pi install -l git:github.com/excloud-in/exc-safety +``` + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/exc-safety.ts b/exc-safety.ts new file mode 100644 index 0000000..7f9b3fb --- /dev/null +++ b/exc-safety.ts @@ -0,0 +1,287 @@ +/** + * 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", + ); + } + }, + }); + + +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..edbe704 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@excloud-in/exc-safety", + "version": "0.1.0", + "description": "Permission gate for exc CLI commands in pi — enforces the same safety model as the Excloud console agent backend", + "keywords": ["pi-package", "excloud", "exc", "safety", "permissions"], + "license": "MIT", + "author": "Excloud", + "repository": { + "type": "git", + "url": "https://github.com/excloud-in/exc-safety" + }, + "pi": { + "extensions": ["./exc-safety.ts"] + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + } +}