From 989fe3103bc77a3b890158578762e8195dee096d Mon Sep 17 00:00:00 2001 From: David Crowe Date: Thu, 17 Sep 2026 16:00:26 -0700 Subject: [PATCH 1/3] Uninstall floor for the two paths the gateway can't reach: no key, gateway down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the gateway's uninstall floor (gatewaystack-connect#1229) in the client-side offline floors, since an outage or a deleted key is exactly when an agent-initiated removal has no human to ask remotely. uninstallFloor() in decide.mjs catches the sanctioned uninstaller in command position, fetching the hosted uninstall script, and a recursive delete of .acp (POSIX or PowerShell, scanned across the whole payload so a variable-bound path in one statement and the delete in another still match) — wired into decide() for local-policy mode. applyOfflineFloors() in govern.mjs did not call it: loadEngine() already required uninstallFloor to exist on the engine module, but the actual outage/no-key/key-rejected code path never invoked it, so the floor was inert exactly where the task needs it most. Wires it in ahead of the destructive floor, with harness-appropriate messaging — Codex hard-denies since it can't ask mid-run; other harnesses get a native ask. Refs gatewaystack-connect#1229 --- bin/decide.mjs | 94 ++++++++++++++++++++++++++++++++++- bin/govern.mjs | 34 ++++++++++++- test/offline-floor.test.mjs | 37 ++++++++++++++ test/uninstall-floor.test.mjs | 76 ++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 test/uninstall-floor.test.mjs diff --git a/bin/decide.mjs b/bin/decide.mjs index 1ecbf32..a271f66 100644 --- a/bin/decide.mjs +++ b/bin/decide.mjs @@ -674,8 +674,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); @@ -684,6 +683,87 @@ 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. + +const UNINSTALL_CMD_RE = /(?:^|[;&|]\s*|\$\(\s*)(?:(?:sudo|doas|env|nice|nohup|setsid|stdbuf|timeout|time|command|builtin)\s+(?:-\S+\s+)*)*(?:\S*[/\\])?acp-uninstall(?:\.cmd)?(?=\s|$|[;&|)])/m; +const UNINSTALL_SCRIPT_RE = /(?:^|[;&|]\s*|\$\(\s*)(?:(?:ba|z|da|k)?sh|pwsh|powershell)(?:\.exe)?\b[^\n;|&]*[/\\]\.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; +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|$)/i; +const VAR_OPERAND_RE = /(?:^|\s|\(|["'])\$(?:\{|env:|[A-Za-z_])/; +const ACP_DIR_MENTION_RE = /(?:^|[\s\\/'"(=])\.acp(?=[\\/'"\s)]|$)/m; +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; + +/** 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]); + const pwsh = PS_SHAPE_RE.test(maskQuotedProse(cmd)); + for (const t of texts) { + const masked = maskQuotedProse(stripDataHeredocs(t)); + if (UNINSTALL_CMD_RE.test(masked) || UNINSTALL_SCRIPT_RE.test(masked)) return "runs the ACP uninstaller"; + if (UNINSTALL_FETCH_RE.test(masked)) return "fetches the ACP uninstaller"; + const mentionsAcp = ACP_DIR_MENTION_RE.test(masked); + RECURSIVE_DELETE_RE.lastIndex = 0; + while ((m = RECURSIVE_DELETE_RE.exec(masked)) !== null) { + const args = m[1] || ""; + if (!RECURSE_FLAG_RE.test(args)) continue; + if (ACP_DIR_MENTION_RE.test(args) || (mentionsAcp && VAR_OPERAND_RE.test(args))) { + return `removes the ACP directory (${pwsh ? "PowerShell" : "shell"})`; + } + } + } + return null; +} + const SEVERITY = { allow: 0, ask: 1, deny: 2 }; /** @@ -734,6 +814,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") { diff --git a/bin/govern.mjs b/bin/govern.mjs index 347e2ad..8e9fef6 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -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; @@ -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 }); diff --git a/test/offline-floor.test.mjs b/test/offline-floor.test.mjs index dfba133..94058d9 100644 --- a/test/offline-floor.test.mjs +++ b/test/offline-floor.test.mjs @@ -89,6 +89,31 @@ test("no key: destructive call asks with the floor label; hardline denies", () = rmSync(home, { recursive: true, force: true }); }); +test("no key: an uninstall attempt asks — the exact branch the floor exists for (gatewaystack-connect#1229)", () => { + const home = freshHome(); + const ask = hook(home, pre("acp-uninstall")); + assert.equal(ask.out.hookSpecificOutput.permissionDecision, "ask"); + assert.match(ask.out.hookSpecificOutput.permissionDecisionReason, /Uninstall floor: runs the ACP uninstaller/); + assert.match(ask.out.hookSpecificOutput.permissionDecisionReason, /nobody can approve it remotely/); + assert.match(ask.out.hookSpecificOutput.permissionDecisionReason, /run `acp-uninstall` yourself/); + const del = hook(home, pre('ACP_DIR="$HOME/.acp"; rm -rf "$ACP_DIR"')); + assert.equal(del.out.hookSpecificOutput.permissionDecision, "ask"); + assert.match(del.out.hookSpecificOutput.permissionDecisionReason, /removes the ACP directory \(shell\)/); + const l = rows(home); + assert.deepEqual(l.map((x) => [x.decision, x.source]), [["ask", "uninstall-floor"], ["ask", "uninstall-floor"]]); + rmSync(home, { recursive: true, force: true }); +}); + +test("no key, Codex: an uninstall attempt hard-denies instead of asking — Codex can't ask mid-run", () => { + const home = freshHome(); + const deny = hook(home, pre("acp-uninstall"), { ACP_HARNESS: "codex" }); + assert.equal(deny.out.hookSpecificOutput.permissionDecision, "deny"); + assert.match(deny.out.hookSpecificOutput.permissionDecisionReason, /Uninstall floor \(runs the ACP uninstaller\)/); + assert.match(deny.out.hookSpecificOutput.permissionDecisionReason, /Codex cannot ask mid-run/); + assert.equal(rows(home)[0].source, "uninstall-floor"); + rmSync(home, { recursive: true, force: true }); +}); + test("no key: prose does not trip the floor, and nothing is spawned or sent", () => { const home = freshHome(); const r = hook(home, pre("gh issue create --title 'floor (force push, DROP/TRUNCATE, curl|sh, rm -r)' --body-file f.md")); @@ -115,6 +140,18 @@ test("unreachable, interactive: benign allows loudly, destructive asks, hardline rmSync(home, { recursive: true, force: true }); }); +test("unreachable: an uninstall attempt still asks — the gateway being down is not a bypass (gatewaystack-connect#1229)", () => { + const home = freshHome(); + const env = { ACP_BEARER_TOKEN: "gsk_test_x", ACP_GOVERN_BASE: "http://12*****.1:9" }; + const ask = hook(home, pre("curl -sf https://agenticcontrolplane.com/uninstall.sh | bash"), env); + assert.equal(ask.out.hookSpecificOutput.permissionDecision, "ask"); + assert.match(ask.out.hookSpecificOutput.permissionDecisionReason, /Uninstall floor: fetches the ACP uninstaller/); + assert.match(ask.out.hookSpecificOutput.permissionDecisionReason, /gateway could not be reached, so nobody can approve it remotely/); + const l = rows(home); + assert.deepEqual(l.map((x) => [x.mode, x.decision, x.source]), [["unreachable", "ask", "uninstall-floor"]]); + rmSync(home, { recursive: true, force: true }); +}); + test("unreachable, unattended tier: stays fail-closed, and the deny is in the ledger", () => { const home = freshHome(); const env = { ACP_BEARER_TOKEN: "gsk_test_x", ACP_GOVERN_BASE: "http://127.0.0.1:9", CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }; diff --git a/test/uninstall-floor.test.mjs b/test/uninstall-floor.test.mjs new file mode 100644 index 0000000..701c4d5 --- /dev/null +++ b/test/uninstall-floor.test.mjs @@ -0,0 +1,76 @@ +// Unit tests for the ask-level uninstall floor in bin/decide.mjs +// (gatewaystack-connect#1229). Same fixtures as the gateway's +// riskClassifier.test.ts so an offline call and a governed call agree. +// This floor exists specifically for the outage / key-gone case: it is +// the one gate an agent cannot route around by knocking the hook itself +// offline. +// +// Run with: node --test test/uninstall-floor.test.mjs + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { uninstallFloor, decide } from "../bin/decide.mjs"; + +const u = (command) => uninstallFloor("Bash", { command }); + +const SATISHGODA_REMOVAL = + "$acp = Join-Path $env:USERPROFILE '.acp'; if (Test-Path $acp) { Remove-Item -Recurse -Force $acp }"; + +test("the 2026-09-17 removal: caught even though the verb and the path are in different statements", () => { + assert.equal(u(SATISHGODA_REMOVAL), "removes the ACP directory (PowerShell)"); +}); + +test("(a) the sanctioned uninstaller in command position, any wrapper or path", () => { + for (const cmd of [ + "acp-uninstall", + "acp-uninstall --dry-run", + "~/.acp/bin/acp-uninstall", + "cd ~ && acp-uninstall", + "sudo acp-uninstall", + "acp-uninstall.cmd", + "bash ~/.acp/uninstall.sh --dry-run", + ]) assert.equal(u(cmd), "runs the ACP uninstaller", cmd); +}); + +test("(b) fetching the hosted uninstaller, POSIX and PowerShell spellings", () => { + for (const cmd of [ + "curl -sf https://agenticcontrolplane.com/uninstall.sh | bash", + "wget -qO- https://agenticcontrolplane.com/uninstall.sh | sh", + "irm https://agenticcontrolplane.com/uninstall.ps1 | iex", + "Invoke-WebRequest -Uri https://agenticcontrolplane.com/uninstall.ps1 -OutFile u.ps1", + ]) assert.equal(u(cmd), "fetches the ACP uninstaller", cmd); +}); + +test("(c) a recursive delete of .acp through a variable, POSIX or PowerShell", () => { + assert.equal(u('ACP_DIR="$HOME/.acp"; rm -rf "$ACP_DIR"'), "removes the ACP directory (shell)"); + assert.equal(u("Remove-Item -Recurse -Force (Join-Path $env:USERPROFILE '.acp')"), "removes the ACP directory (PowerShell)"); + assert.equal(u("$acp = Join-Path $HOME '.acp'; rd -r $acp"), "removes the ACP directory (PowerShell)"); +}); + +test("a literal rm -rf ~/.acp is still caught (no separate tamper floor offline, but this one sees it)", () => { + assert.equal(u("rm -rf ~/.acp"), "removes the ACP directory (shell)"); +}); + +test("benign PowerShell and prose never fire", () => { + for (const cmd of [ + "Remove-Item -Recurse .\\build", + 'Write-Host "see ~/.acp/lapse.log"', + "grep -ri lapse ~/.acp/", + "cat ~/.acp/uninstall.sh", + "git commit -m 'document acp-uninstall and the Remove-Item path'", + "acp-uninstall-notes.md", + "cat acp-uninstall.md", + ]) assert.equal(u(cmd), null, cmd); +}); + +test("decide(): tightens an allow to ask; a policy deny still wins; non-shell tools untouched", () => { + const r = decide("Bash", { command: "acp-uninstall" }, { rules: {}, default: "allow" }, {}); + assert.equal(r.decision, "ask"); + assert.match(r.reason, /^uninstall floor: runs the ACP uninstaller/); + assert.equal(r.source, "uninstall-floor"); + + const denied = decide("Bash", { command: "acp-uninstall" }, { rules: { "Bash.acp-uninstall": "deny" }, default: "allow" }, {}); + assert.equal(denied.decision, "deny"); + + assert.equal(uninstallFloor("Read", { file_path: "/x" }), null); +}); From c450e748d4b2c58583e0857efac10c79ab1c94d5 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Mon, 21 Sep 2026 10:56:46 -0700 Subject: [PATCH 2/3] Uninstall floor: mirror the gateway's per-statement rule and coverage; runLocal uses the vetted engine (gatewaystack-connect#1277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decide.mjs keeps parity with the gateway's riskClassifier.ts: - HIGH-1: `.acp;` / `.acp&` / `.acp,` are the directory — the semicolon-joined variable spellings fire offline too. - HIGH-2: classifyTool strips data heredocs (and their terminator) before segmenting, so a heredoc authoring a .ps1 is Bash.cat, not Bash.Remove-Item; the floor's PowerShell label ignores comments and heredoc bodies the same way. - MED-3: per-statement variable rule with cross-segment bindings and pipeline operands; `.acp` elsewhere in the payload no longer asks. - MED-4: quoted full path, npx, source/./& launchers, direct execution, find -delete/-exec, inline python/node/perl/ruby tree deletes, cmd.exe `rd /s` + `%VAR%`, `claude plugin disable|uninstall|remove`. - MED-6: runLocal() takes the engine loadEngine() already vetted, so ACP_LOCAL=1 never imports a stale ~/.acp/decide.mjs that lacks uninstallFloor; the fail-open notice is unchanged when no engine loads. Co-Authored-By: Claude Fable 5.1 --- bin/decide.mjs | 138 +++++++++++++++++++++++++++++----- bin/govern.mjs | 32 ++++---- test/local-mode.test.mjs | 23 ++++++ test/uninstall-floor.test.mjs | 80 +++++++++++++++++++- 4 files changed, 236 insertions(+), 37 deletions(-) diff --git a/bin/decide.mjs b/bin/decide.mjs index a271f66..dba6375 100644 --- a/bin/decide.mjs +++ b/bin/decide.mjs @@ -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; @@ -719,17 +737,100 @@ export function maskQuotedProse(s) { // Same fixtures as the gateway's floor, so an offline call and a governed // call agree. -const UNINSTALL_CMD_RE = /(?:^|[;&|]\s*|\$\(\s*)(?:(?:sudo|doas|env|nice|nohup|setsid|stdbuf|timeout|time|command|builtin)\s+(?:-\S+\s+)*)*(?:\S*[/\\])?acp-uninstall(?:\.cmd)?(?=\s|$|[;&|)])/m; -const UNINSTALL_SCRIPT_RE = /(?:^|[;&|]\s*|\$\(\s*)(?:(?:ba|z|da|k)?sh|pwsh|powershell)(?:\.exe)?\b[^\n;|&]*[/\\]\.acp[/\\]uninstall\.(?:sh|ps1)\b/im; +// 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|$)/i; -const VAR_OPERAND_RE = /(?:^|\s|\(|["'])\$(?:\{|env:|[A-Za-z_])/; -const ACP_DIR_MENTION_RE = /(?:^|[\s\\/'"(=])\.acp(?=[\\/'"\s)]|$)/m; +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 || ""); @@ -746,19 +847,22 @@ export function uninstallFloor(toolName, toolInput) { let m; PWSH_COMMAND_RE.lastIndex = 0; while ((m = PWSH_COMMAND_RE.exec(cmd)) !== null) texts.push(m[2]); - const pwsh = PS_SHAPE_RE.test(maskQuotedProse(cmd)); + // 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 = maskQuotedProse(stripDataHeredocs(t)); - if (UNINSTALL_CMD_RE.test(masked) || UNINSTALL_SCRIPT_RE.test(masked)) return "runs the ACP uninstaller"; + 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"; - const mentionsAcp = ACP_DIR_MENTION_RE.test(masked); - RECURSIVE_DELETE_RE.lastIndex = 0; - while ((m = RECURSIVE_DELETE_RE.exec(masked)) !== null) { - const args = m[1] || ""; - if (!RECURSE_FLAG_RE.test(args)) continue; - if (ACP_DIR_MENTION_RE.test(args) || (mentionsAcp && VAR_OPERAND_RE.test(args))) { - return `removes the ACP directory (${pwsh ? "PowerShell" : "shell"})`; - } + 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; diff --git a/bin/govern.mjs b/bin/govern.mjs index 8e9fef6..9411dd9 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -698,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 diff --git a/test/local-mode.test.mjs b/test/local-mode.test.mjs index 5b75211..58c12f1 100644 --- a/test/local-mode.test.mjs +++ b/test/local-mode.test.mjs @@ -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 }); + } +}); diff --git a/test/uninstall-floor.test.mjs b/test/uninstall-floor.test.mjs index 701c4d5..24a073f 100644 --- a/test/uninstall-floor.test.mjs +++ b/test/uninstall-floor.test.mjs @@ -9,7 +9,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { uninstallFloor, decide } from "../bin/decide.mjs"; +import { uninstallFloor, decide, classifyTool } from "../bin/decide.mjs"; const u = (command) => uninstallFloor("Bash", { command }); @@ -63,6 +63,84 @@ test("benign PowerShell and prose never fire", () => { ]) assert.equal(u(cmd), null, cmd); }); +test("#1277 HIGH-1: the semicolon-joined variable spellings fire (`.acp;` is the dir)", () => { + for (const cmd of [ + 'd=~/.acp; rm -rf "$d"', + "d=$HOME/.acp; rm -rf $d", + "export ACP=~/.acp; rm -rf $ACP", + "sh -c 'd=~/.acp; rm -rf $d'", + "d=~/.acp\nrm -rf \"$d\"", + "d=~/.acp && rm -rf \"$d\"", + "(d=~/.acp; rm -rf \"$d\")", + "d=$(echo ~/.acp); rm -rf $d", + ]) assert.equal(u(cmd), "removes the ACP directory (shell)", cmd); + assert.equal( + u("$acp = Join-Path $env:USERPROFILE '.acp'\nif (Test-Path $acp) {\n Remove-Item -Recurse -Force $acp\n}"), + "removes the ACP directory (PowerShell)", + ); +}); + +test("#1277 MED-3: the variable rule is per statement — `.acp` elsewhere in the payload is not an operand", () => { + for (const cmd of [ + "rm -rf $TMP && cat ~/.acp/lapse.log", + 'for d in $(ls); do rm -rf "$d"; done; cat .acp/config', + "rm -r $BUILD_DIR # keep .acp", + 'DIR=dist; rm -rf "$DIR"; echo \'see .acp/lapse.log\'', + 'rm -rf "$OUT"; ls .acp', + "rm -rf $DIR\nls ~/.acp", + "Remove-Item -Recurse -Force $build; Write-Host \"acp: see .acp/lapse.log\"", + "rmdir /s /q build && dir .acp", + ]) assert.equal(u(cmd), null, cmd); +}); + +test("#1277 MED-4: wrappers, quoted paths, launchers, find, inline scripts, pipelines, cmd.exe, plugin disable", () => { + for (const cmd of [ + "command acp-uninstall", + '"$HOME/.acp/bin/acp-uninstall"', + "npx acp-uninstall", + "source ~/.acp/uninstall.sh", + ". ~/.acp/uninstall.sh", + "bash < ~/.acp/uninstall.sh", + "~/.acp/uninstall.sh", + "sudo bash ~/.acp/uninstall.sh", + '& "$env:USERPROFILE\\.acp\\uninstall.ps1"', + ]) assert.equal(u(cmd), "runs the ACP uninstaller", cmd); + for (const cmd of [ + "find ~/.acp -delete", + "find ~ -maxdepth 1 -name .acp -exec rm -rf {} +", + "Get-ChildItem $env:USERPROFILE\\.acp -Recurse | Remove-Item -Force -Recurse", + "ls -d ~/.acp | xargs rm -rf", + "set d=%USERPROFILE%\\.acp & rd /s /q %d%", + ]) assert.match(u(cmd), /^removes the ACP directory/, cmd); + for (const cmd of [ + "python3 -c \"import shutil,os; shutil.rmtree(os.path.expanduser('~/.acp'))\"", + "node -e \"require('fs').rmSync(require('os').homedir()+'/.acp',{recursive:true})\"", + ]) assert.equal(u(cmd), "removes the ACP directory (script)", cmd); + for (const cmd of [ + "claude plugin disable agentic-control-plane", + "claude plugin uninstall acp", + "claude plugin remove agentic-control-plane@acp-marketplace", + ]) assert.equal(u(cmd), "disables the ACP plugin", cmd); +}); + +test("#1277 adversarial: the review's refuted false positives stay null; comments/heredocs don't flip the label", () => { + for (const cmd of [ + "rm -rf node_modules/.acp-cache", + "rm -rf .acpx", + "rm -rf ~/.acp-backup", + "npm uninstall acp-client", + "git checkout -- .acp", + "docker rm -f acp-uninstall", + "cat docs/acp-uninstall.md", + "Remove-Item -Recurse .\\build", + "claude plugin disable acp-foo", + "find . -name '*.pyc' -delete", + ]) assert.equal(u(cmd), null, cmd); + assert.equal(u("d=~/.acp; rm -rf $d # Remove-Item"), "removes the ACP directory (shell)"); + assert.equal(classifyTool("Bash", { command: "rm -rf ~/work/scratch # Remove-Item" }), "Bash.rm"); + assert.equal(classifyTool("Bash", { command: "cat > deploy.ps1 <<'EOF'\nRemove-Item -Recurse dist\nEOF" }), "Bash.cat"); +}); + test("decide(): tightens an allow to ask; a policy deny still wins; non-shell tools untouched", () => { const r = decide("Bash", { command: "acp-uninstall" }, { rules: {}, default: "allow" }, {}); assert.equal(r.decision, "ask"); From bbc010ea9b39bc2b47c6bec89cf080e90f21ce96 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Mon, 21 Sep 2026 14:11:44 -0700 Subject: [PATCH 3/3] Bump to 0.25.0 for the uninstall floor Co-Authored-By: Claude Fable 5.1 --- .claude-plugin/marketplace.json | 2 +- bin/govern.mjs | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7fbfff4..11f18da 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index 65964d8..0e45ba0 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -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 = diff --git a/plugin.json b/plugin.json index 029c0fa..dfa75a2 100644 --- a/plugin.json +++ b/plugin.json @@ -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",