init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
42
README.md
Normal 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
287
exc-safety.ts
Normal 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
18
package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user