Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "agentic-control-plane",
"source": "./",
"description": "Control, audit, and cost-optimize every Claude Code tool call. Governance hook + bundled ACP MCP (cost X-ray, run traces, policy checks) + /cost-xray pre-ship report.",
"version": "0.24.0",
"version": "0.25.0",
"author": {
"name": "GatewayStack"
},
Expand Down
200 changes: 197 additions & 3 deletions bin/decide.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,31 @@ function bashUnits(toolName, toolInput) {
* by a Bash.unknown rule, still falls back to "Bash" in the policy walk, and
* honestly labeled as unparsed in the audit line rather than silently benign.
*/
const HEREDOC_DELIM_RE = /<<(?!<)-?\s*(?:"([A-Za-z_][\w-]*)"|'([A-Za-z_][\w-]*)'|\\?([A-Za-z_][\w-]*))/g;
/** Drop heredoc terminator lines (`EOF`) from stripped text: stripDataHeredocs
* keeps them so its output still pairs, but for classification they are
* not commands. `raw` is the original text the delimiters come from. */
function stripHeredocTerminators(stripped, raw) {
if (!raw.includes("<<")) return stripped;
const delims = new Set();
let m;
HEREDOC_DELIM_RE.lastIndex = 0;
while ((m = HEREDOC_DELIM_RE.exec(raw)) !== null) delims.add(m[1] || m[2] || m[3]);
if (!delims.size) return stripped;
return stripped.split("\n").filter((l) => !delims.has(l.trim())).join("\n");
}

export function classifyTool(toolName, toolInput) {
const name = String(toolName || "");
const input = typeof toolInput === "string" ? safeParse(toolInput) : (toolInput || {});

if (name === "Bash" || name === "run_terminal_cmd" || name === "shell") {
const cmd = String(input.command || input.cmd || "");
const units = commandUnits(cmd);
// A heredoc body written to a file is data, not a command line: a
// `cat > deploy.ps1 <<'EOF' … Remove-Item … EOF` is Bash.cat, the same
// class the gateway gives it (gatewaystack-connect#1277 HIGH-2). The
// terminator line is dropped too — a lone `EOF` is not a command.
const units = commandUnits(stripHeredocTerminators(stripDataHeredocs(cmd), cmd));
if (!units.length) return cmd.trim() ? "Bash.unknown" : "Bash";
let best = units[0];
for (const u of units) if (privilegeRank(u.bin) > privilegeRank(best.bin)) best = u;
Expand Down Expand Up @@ -674,8 +692,7 @@ export function destructiveFloor(toolName, toolInput, context) {
}
for (const t of texts) {
const stripped = stripDataHeredocs(t);
// Quoted spans with whitespace are prose, not commands.
const masked = stripped.replace(/'[^']*\s[^']*'/g, "''").replace(/"[^"]*\s[^"]*"/g, '""');
const masked = maskQuotedProse(stripped);
if (FORCE_PUSH_RE.test(masked)) return "force-pushes over shared git history";
if (PIPE_TO_SHELL_RE.test(masked) || SHELL_OF_DOWNLOAD_RE.test(t)) return "pipes a remote download into a shell";
const rm = recursiveDeleteOutsideCwd(stripped, context && context.cwd);
Expand All @@ -684,6 +701,173 @@ export function destructiveFloor(toolName, toolInput, context) {
return null;
}

/** Blank quoted spans that contain whitespace — prose, not commands — in
* one left-to-right scan that pairs quotes the way the shell does. The
* global-regex version paired a CLOSING quote with the next opening one:
* in `A="x"; rm -rf "/"` it masked `; rm -rf ` as prose and the command
* between two short quoted arguments vanished from the floor's view
* (gatewaystack-connect#1229). */
export function maskQuotedProse(s) {
let out = "";
for (let i = 0; i < s.length; ) {
const ch = s[i];
if (ch !== "'" && ch !== '"') { out += ch; i++; continue; }
let j = i + 1;
while (j < s.length && !(s[j] === ch && s[j - 1] !== "\\")) j++;
const inner = s.slice(i + 1, j);
if (j >= s.length) { out += ch + inner; break; }
out += /\s/.test(inner) ? ch + ch : ch + inner + ch;
i = j + 1;
}
return out;
}

// ── Uninstall floor (ask-level; gatewaystack-connect#1229) ─────────────
// Easy for the human, not for the agent. A human typing `acp-uninstall` in
// a terminal never passes through this hook. An AGENT removing ACP asks —
// and the OFFLINE floor is the one that matters most here: an outage, or
// deleting the key first, must not be the uninstall path. Three shapes:
// (a) the sanctioned uninstaller: `acp-uninstall` / `acp-uninstall.cmd`
// in command position, or a shell/pwsh running the cached copy;
// (b) fetching the hosted uninstaller (curl/wget/irm/iwr/Invoke-*);
// (c) a recursive delete aimed at `.acp`, or at a VARIABLE in a payload
// that also names `.acp` — the 2026-09-17 removal bound the path two
// statements earlier (`$acp = Join-Path $env:USERPROFILE '.acp'`)
// and deleted `$acp`; no per-segment verb+path rule can see that.
// Same fixtures as the gateway's floor, so an offline call and a governed
// call agree.

// Mirrors the gateway's riskClassifier.ts (#1277): same regexes, same
// per-statement variable rule, so an offline call and a governed call agree.
const UNINSTALL_WRAPPER = String.raw`(?:(?:sudo|doas|env|nice|nohup|setsid|stdbuf|timeout|time|command|builtin)\s+(?:-\S+\s+)*)*`;
// `command acp-uninstall`, `npx acp-uninstall`, and a QUOTED full path
// (`"$HOME/.acp/bin/acp-uninstall"`) are still the uninstaller in command position.
const UNINSTALL_CMD_RE = new RegExp(
String.raw`(?:^|[;&|]\s*|\$\(\s*)${UNINSTALL_WRAPPER}(?:(?:npx|pnpx|bunx)\s+(?:-\S+\s+)*)?['"]?(?:\S*[/\\])?acp-uninstall(?:\.cmd)?(?=['"\s;&|)]|$)`,
"m",
);
// The cached uninstaller run by a launcher (`bash …`, `bash < …`, `source …`,
// `. …`, PowerShell's `& "…\uninstall.ps1"`) …
const UNINSTALL_SCRIPT_RE = new RegExp(
String.raw`(?:^|[;&|]\s*|\$\(\s*)${UNINSTALL_WRAPPER}(?:(?:(?:ba|z|da|k)?sh|pwsh|powershell)(?:\.exe)?\b|source\b|\.(?=\s)|&(?=\s))[^\n;|&]*?[/\\]\.acp[/\\]uninstall\.(?:sh|ps1)\b`,
"im",
);
// … or executed directly (`~/.acp/uninstall.sh`).
const UNINSTALL_SCRIPT_DIRECT_RE = /(?:^|[;&|]\s*|\$\(\s*)['"]?\S*[/\\]\.acp[/\\]uninstall\.(?:sh|ps1)\b/im;
const UNINSTALL_FETCH_RE = /\b(?:curl|wget|irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]*?agenticcontrolplane\.com\/uninstall\.(?:sh|ps1)\b/i;
// `find ~/.acp -delete` / `find ~ -name .acp -exec rm -rf {} +`.
const FIND_DELETE_RE = /\bfind\b[^\n;|&]*?[\s'"/\\]\.acp(?![\w.-])[^\n;|&]*?\s(?:-delete\b|-exec\s+(?:\S*[/\\])?(?:rm|rmdir|unlink)\b)/i;
// Inline scripts are quoted spans with whitespace (blanked by the prose
// mask): scan the RAW literal for a tree-delete call naming .acp.
const SCRIPT_LITERAL_RE = /\b(?:python\d?(?:\.\d+)?|node|ruby|perl)\b[^\n]*?\s-[a-zA-Z]*[ce]\s+(['"])([\s\S]*?)\1/gi;
const SCRIPT_DELETE_CALL_RE = /\b(?:rmtree|rmSync|rmdirSync|rm_rf|rm_r|remove_tree|rimraf|removeSync|remove_dir_all)\b/;
const SCRIPT_ACP_RE = /[/\\'"]\.acp(?![\w.-])/;
// Turning the plugin off is the other exit.
const PLUGIN_DISABLE_RE = new RegExp(
String.raw`(?:^|[;&|]\s*|\$\(\s*)${UNINSTALL_WRAPPER}(?:\S*[/\\])?claude(?:\.cmd|\.exe)?\s+plugins?\s+(?:disable|uninstall|remove|rm)\b[^\n;|&]*?(?:\s|['"])(?:agentic-control-plane|acp)(?![\w-])`,
"im",
);
// Any recursive-delete statement, POSIX / PowerShell / cmd.exe (`rd /s`).
const RECURSIVE_DELETE_RE = /\b(?:remove-item|ri|rm|del|erase|rd|rmdir)\b([^\n;|&]*)/gi;
const RECURSE_FLAG_RE = /\s(?:-(?!force\b)(?:recurse|[a-z]{0,3}r[a-z]{0,3})|\/s)(?=\s|$)/i;
const VAR_OPERAND_RE = /(?:^|\s|\(|["'])(?:\$(?:\{|env:|[A-Za-z_])|%[A-Za-z_]\w*%)/;
// `.acp` as a path component: NOT continued by a name character, so
// `.acp;` / `.acp&` / `.acp,` are the dir; `.acpx`, `.acp-cache` are not.
const ACP_DIR_MENTION_RE = /(?:^|[\s\\/'"(=])\.acp(?![\w.-])/m;
const SEGMENT_SPLIT_RE = /(\n|;|&&|\|\||\||&)/;
const ASSIGN_RE = /(?:^|[\s(;{])(?:(?:export|declare|local|readonly|typeset|set)\s+(?:-\w+\s+)*)?\$?(?:env:)?([A-Za-z_]\w*)\s*=(?!=)/gi;
const LOOP_BIND_RE = /\b(?:for|foreach)\s*\(?\s*\$?([A-Za-z_]\w*)\s+in\b/gi;
const VAR_REF_RE = /\$\{?(?:env:)?([A-Za-z_]\w*)|%([A-Za-z_]\w*)%/g;
const PS_SHAPE_RE = /\$env:[A-Za-z_]\w*|\[[A-Za-z][\w.]*\]::|\s-ErrorAction\b|\b(?:Join-Path|Remove-Item|Write-Host|Test-Path|Get-ChildItem|Get-Content|Set-Content|Out-File|New-Item|Copy-Item|Move-Item|Invoke-WebRequest|Invoke-RestMethod|Invoke-Expression|Start-Process)\b/i;
// PowerShell's `-Command`/`-c` string is a launder the same way `sh -c` is.
const PWSH_COMMAND_RE = /\b(?:powershell|pwsh)(?:\.exe)?\b[^'"\n;|&]*?\s-(?:c|command)\s+(['"])([\s\S]*?)\1/gi;

/** Drop `# …` comments (a `#` at line start or after whitespace). Apply
* AFTER the quote mask so a `#` inside a short quoted operand survives. */
export function stripShellComments(s) {
return s.includes("#") ? s.replace(/(^|\s)#[^\n]*/g, "$1") : s;
}

function referencesVar(text, names) {
if (!names.size) return false;
VAR_REF_RE.lastIndex = 0;
let m;
while ((m = VAR_REF_RE.exec(text)) !== null) {
if (names.has((m[1] || m[2] || "").toLowerCase())) return true;
}
return false;
}

/** The variable rule, per statement: a recursive delete of `$var` fires only
* when its OWN segment names `.acp`, or `$var` was bound to a `.acp` path in
* an earlier segment, or the operand arrives by pipeline from a segment
* that names `.acp`. */
function recursiveDeleteOfAcp(masked) {
const parts = masked.split(SEGMENT_SPLIT_RE);
const acpVars = new Set();
let prevMentions = false;
for (let i = 0; i < parts.length; i += 2) {
const seg = parts[i];
const sep = i > 0 ? parts[i - 1] : "";
const mentions = ACP_DIR_MENTION_RE.test(seg) || referencesVar(seg, acpVars);
if (mentions) {
let b;
ASSIGN_RE.lastIndex = 0;
while ((b = ASSIGN_RE.exec(seg)) !== null) acpVars.add(b[1].toLowerCase());
LOOP_BIND_RE.lastIndex = 0;
while ((b = LOOP_BIND_RE.exec(seg)) !== null) acpVars.add(b[1].toLowerCase());
}
const piped = sep === "|" && prevMentions;
prevMentions = mentions;
RECURSIVE_DELETE_RE.lastIndex = 0;
let m;
while ((m = RECURSIVE_DELETE_RE.exec(seg)) !== null) {
const args = m[1] || "";
if (!RECURSE_FLAG_RE.test(args)) continue;
if (ACP_DIR_MENTION_RE.test(args) || piped) return true;
if (VAR_OPERAND_RE.test(args) && (mentions || referencesVar(args, acpVars))) return true;
}
}
return false;
}

/** Ask-level floor for an agent-initiated ACP removal: the label, or null. */
export function uninstallFloor(toolName, toolInput) {
const name = String(toolName || "");
if (name !== "Bash" && name !== "run_terminal_cmd" && name !== "shell") return null;
const input = typeof toolInput === "string" ? safeParse(toolInput) : (toolInput || {});
const cmd = String(input.command || input.cmd || "");
if (!cmd) return null;
const texts = [cmd];
for (const seg of splitSegments(cmd)) {
const { bin, args } = parseCommand(seg);
const inner = innerShellCommand(bin, args);
if (inner) texts.push(inner);
}
let m;
PWSH_COMMAND_RE.lastIndex = 0;
while ((m = PWSH_COMMAND_RE.exec(cmd)) !== null) texts.push(m[2]);
// Heredoc bodies and comments do not make a line PowerShell (#1277).
const pwsh = PS_SHAPE_RE.test(stripShellComments(maskQuotedProse(stripDataHeredocs(cmd))));
for (const t of texts) {
const masked = stripShellComments(maskQuotedProse(stripDataHeredocs(t)));
if (UNINSTALL_CMD_RE.test(masked) || UNINSTALL_SCRIPT_RE.test(masked) || UNINSTALL_SCRIPT_DIRECT_RE.test(masked)) {
return "runs the ACP uninstaller";
}
if (UNINSTALL_FETCH_RE.test(masked)) return "fetches the ACP uninstaller";
if (PLUGIN_DISABLE_RE.test(masked)) return "disables the ACP plugin";
if (FIND_DELETE_RE.test(masked) || recursiveDeleteOfAcp(masked)) {
return `removes the ACP directory (${pwsh ? "PowerShell" : "shell"})`;
}
SCRIPT_LITERAL_RE.lastIndex = 0;
let s;
while ((s = SCRIPT_LITERAL_RE.exec(t)) !== null) {
if (SCRIPT_DELETE_CALL_RE.test(s[2]) && SCRIPT_ACP_RE.test(s[2])) return "removes the ACP directory (script)";
}
}
return null;
}

const SEVERITY = { allow: 0, ask: 1, deny: 2 };

/**
Expand Down Expand Up @@ -734,6 +918,16 @@ export function decide(toolName, toolInput, policy, context) {
return { decision: def, reason: `local policy: default → ${def}`, source: "default", classified: key, contextGuard: shadow };
})();

// Uninstall floor (gatewaystack-connect#1229): an agent removing ACP
// asks, in every mode — a policy allow cannot loosen it. Checked before
// the destructive floor so the human reads what is actually happening
// ("removes ACP") rather than the generic shape ("pipes a download").
if (result.decision === "allow") {
const exit = uninstallFloor(toolName, toolInput);
if (exit) {
return { decision: "ask", reason: `uninstall floor: ${exit}`, source: "uninstall-floor", classified: key, contextGuard: shadow, floor: exit };
}
}
// Destructive floor (#1097): tightens an allow to ask in every mode. A
// policy deny or ask already stands; a policy allow cannot loosen it.
if (result.decision === "allow") {
Expand Down
68 changes: 47 additions & 21 deletions bin/govern.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const ACP_GOVERN =
process.env.ACP_API_BASE ||
"https://govern.agenticcontrolplane.com";

const PLUGIN_VERSION = "0.24.0";
const PLUGIN_VERSION = "0.25.0";

// Console base for user-facing deep links (session receipt, #606).
const ACP_CONSOLE =
Expand Down Expand Up @@ -462,7 +462,10 @@ async function loadEngine() {
for (const spec of [pathToFileURL(join(ACP_DIR, "decide.mjs")).href, "./decide.mjs"]) {
try {
const m = await import(spec);
if (typeof m.decide === "function" && typeof m.hardlineFloor === "function" && typeof m.destructiveFloor === "function") return m;
// uninstallFloor (gatewaystack-connect#1229) is required too: an
// installed copy that predates it is skipped for the bundled one,
// so the exit is governed offline before the installer catches up.
if (typeof m.decide === "function" && typeof m.hardlineFloor === "function" && typeof m.destructiveFloor === "function" && typeof m.uninstallFloor === "function") return m;
} catch { /* try the next */ }
}
return null;
Expand Down Expand Up @@ -516,6 +519,35 @@ function applyOfflineFloors(input, mode) {
}));
return true;
}
// Uninstall floor (#1229) — checked before the destructive floor, same
// order as decide(), and the reason names what is actually happening
// ("removes ACP") instead of the generic shape. This is the branch the
// whole floor exists for: an outage or a deleted key is exactly when an
// agent-initiated uninstall must still ask, not slide through on
// "nothing to check against."
const exit = ENGINE.uninstallFloor(input.tool_name, input.tool_input);
if (exit) {
ledgerRecord(input, { decision: "ask", source: "uninstall-floor", reason: exit, mode });
const why = mode === "no-key"
? "ACP has no key on this machine, so nobody can approve it remotely"
: "the gateway could not be reached, so nobody can approve it remotely";
if (HARNESS === "codex") {
const reason = `[ACP] Uninstall floor (${exit}) — ${why}. Codex cannot ask mid-run, so the call is blocked; a human runs it, or run \`acp-uninstall\` yourself in a terminal.`;
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
systemMessage: reason,
}));
} else {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: `[ACP] Uninstall floor: ${exit} — ${why}. An agent is trying to remove ACP from this machine. If that's you, approve — or run \`acp-uninstall\` yourself in a terminal.`,
},
}));
}
return true;
}
const soft = ENGINE.destructiveFloor(input.tool_name, input.tool_input, { cwd: input.cwd });
if (soft) {
ledgerRecord(input, { decision: "ask", source: "destructive-floor", reason: soft, mode });
Expand Down Expand Up @@ -666,26 +698,20 @@ async function runLocal(input) {
try {
policy = JSON.parse(readFileSync(join(ACP_DIR, "policy.json"), "utf8"));
} catch { /* no/invalid policy → defaults above; the safety floor still applies */ }
// The decision engine: prefer the installed copy (~/.acp/decide.mjs, kept
// current by the installer), fall back to the copy bundled next to this
// file (standalone plugin installs that never ran install.sh).
let decide;
try {
({ decide } = await import(pathToFileURL(join(ACP_DIR, "decide.mjs")).href));
} catch {
try {
({ decide } = await import("./decide.mjs"));
} catch {
// Engine missing/corrupt → never brick, but NEVER silently: say it
// loud and leave an audit line, same contract as the cloud path.
audit({ ts: new Date().toISOString(), event: "pre", client: ACP_CLIENT, tool: input.tool_name,
decision: "allow", source: "fail-open", reason: "local engine unavailable (~/.acp/decide.mjs)" });
process.stdout.write(JSON.stringify({
systemMessage: "[ACP·local] ⚠ decision engine unavailable (~/.acp/decide.mjs) — this call ran UNGOVERNED and was allowed. Re-run the installer to restore it.",
}));
return;
}
// The decision engine is the one loadEngine() already vetted (#1277
// MED-6): an installed ~/.acp/decide.mjs that predates uninstallFloor is
// skipped for the bundled copy here too, so ACP_LOCAL=1 never runs the
// exit through a stale engine. Missing everywhere → never brick, but
// NEVER silently: say it loud and leave an audit line.
if (!ENGINE) {
audit({ ts: new Date().toISOString(), event: "pre", client: ACP_CLIENT, tool: input.tool_name,
decision: "allow", source: "fail-open", reason: "local engine unavailable (~/.acp/decide.mjs)" });
process.stdout.write(JSON.stringify({
systemMessage: "[ACP·local] ⚠ decision engine unavailable (~/.acp/decide.mjs) — this call ran UNGOVERNED and was allowed. Re-run the installer to restore it.",
}));
return;
}
const { decide } = ENGINE;
const ctx = await readContext(input);
const d = decide(input.tool_name, input.tool_input, policy, { ...(ctx || {}), harness: HARNESS, cwd: input.cwd });
// The ledger mirrors audit.jsonl for local-policy calls so a later connect
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentic-control-plane",
"version": "0.24.0",
"version": "0.25.0",
"description": "Identity, governance, and audit for every Claude Code tool call. Logs all tool usage, enforces policies, and gives teams full visibility \u2014 without changing how you use Claude.",
"author": {
"name": "GatewayStack",
Expand Down
23 changes: 23 additions & 0 deletions test/local-mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,26 @@ test("ACP_LOCAL=1 with no policy file: floor still active, default allow for the
rmSync(bare, { recursive: true, force: true });
}
});

test("#1277 MED-6: ACP_LOCAL=1 with a STALE ~/.acp/decide.mjs (no uninstallFloor) still asks on acp-uninstall — runLocal uses the same vetted engine as loadEngine", () => {
const stale = mkdtempSync(join(tmpdir(), "acp-local-stale-"));
try {
mkdirSync(join(stale, ".acp"), { recursive: true });
// An installed engine from before the uninstall floor: decide + the two
// older floors, nothing else. Re-exported from the bundled copy so it is
// otherwise a working engine, not a corrupt one.
writeFileSync(
join(stale, ".acp", "decide.mjs"),
`export { decide, hardlineFloor, destructiveFloor } from ${JSON.stringify(DECIDE)};\n`,
);
writeFileSync(join(stale, ".acp", "policy.json"), JSON.stringify({ default: "allow", rules: {} }));
const out = hook(pre("acp-uninstall"), { HOME: stale, ACP_LOCAL: "1" });
assert.equal(out.hookSpecificOutput.permissionDecision, "ask");
assert.match(out.hookSpecificOutput.permissionDecisionReason, /uninstall floor/i);
// Control: the stale engine still decides an ordinary call locally.
const ok = hook(pre("git status"), { HOME: stale, ACP_LOCAL: "1" });
assert.notEqual(ok?.hookSpecificOutput?.permissionDecision, "deny");
} finally {
rmSync(stale, { recursive: true, force: true });
}
});
Loading
Loading