This commit is contained in:
lolwierd
2026-04-27 11:50:41 +05:30
commit 248cff41d3
5 changed files with 371 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store

21
LICENSE Normal file
View File

@@ -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.

42
README.md Normal file
View File

@@ -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).

287
exc-safety.ts Normal file
View File

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

18
package.json Normal file
View File

@@ -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": "*"
}
}