Skip to content

Uninstall floor: mirror the gateway's per-statement rule and coverage; runLocal uses the vetted engine (gatewaystack-connect#1277) - #42

Merged
davidcrowe merged 4 commits into
mainfrom
feat/uninstall-floor
Sep 21, 2026
Merged

davidcrowe merged 4 commits into
mainfrom
feat/uninstall-floor

Conversation

@davidcrowe

@davidcrowe davidcrowe commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Plugin half of the feat/uninstall-floor rework per the 2026-09-21 adversarial review, davidcrowe/gatewaystack-connect#1277. Companion gateway PR: https://github.com/davidcrowe/gatewaystack-connect/pull/1286 (carries the full before/after probe table; bin/decide.mjs mirrors the gateway riskClassifier.ts and agrees with it on every row).

What changed

  • HIGH-1 ACP_DIR_MENTION_RE lookahead is (?![\w.-]): d=~/.acp; rm -rf "$d", d=$HOME/.acp; rm -rf $d, export ACP=~/.acp; rm -rf $ACP, sh -c 'd=~/.acp; rm -rf $d' fire offline; .acpx / .acp-cache / .acp_old do not.
  • HIGH-2 classifyTool strips data heredocs (and, via a new stripHeredocTerminators, the lone EOF line) before segmenting: a heredoc authoring a .ps1 is Bash.cat, not Bash.Remove-Item / Bash.EOF; rm -rf ~/work/scratch # Remove-Item stays Bash.rm. The floor's "(PowerShell)" label is computed on comment- and heredoc-stripped text, matching the gateway's routing.
  • MED-3 Per-statement variable rule (recursiveDeleteOfAcp), identical to the gateway's: split on ; && || | & keeping the operator; bindings (d=, export, $d =, $env:, set, for … in, foreach ($d in) tracked across segments and $var / %var% resolved; pipeline operand from a .acp-naming source. rm -rf $TMP && cat ~/.acp/lapse.log and the for … done; cat .acp/config loop are null.
  • MED-4 Quoted full path, npx acp-uninstall, source / . / & launchers, bash < …, direct execution of ~/.acp/uninstall.sh, find … -delete|-exec rm, inline python3 -c / node -e / perl -e / ruby -e tree deletes ("(script)" label), cmd.exe rd /s + %VAR%, claude plugin disable|uninstall|remove agentic-control-plane|acp ("disables the ACP plugin").
  • MED-6 runLocal() no longer imports ~/.acp/decide.mjs on its own: it takes the module-level ENGINE that loadEngine() already vetted for uninstallFloor, so ACP_LOCAL=1 with a stale installed engine still asks on acp-uninstall. The fail-open notice + audit line are unchanged when no engine loads at all.

Left out, on purpose

Same list as the gateway PR: glob / quote-splitting spellings, rm -rf "$(echo ~/.acp)" (whitespace-bearing quoted span blanked by the shared prose mask), read … <<<, rsync --delete, symlink-then-delete, echo 'Write-Host' as a PowerShell shape, the plugin cache dir under ~/.claude/plugins.

Verification

  • node --test test/: 262/262 (was 257; +4 in test/uninstall-floor.test.mjs, +1 MED-6 end-to-end in test/local-mode.test.mjs with a stale ~/.acp/decide.mjs that exports only decide/hardlineFloor/destructiveFloor).
  • Probe rows for this branch vs df3dafd are the "plugin before / plugin after" columns in the gateway PR.

davidcrowe and others added 4 commits September 21, 2026 10:36
…teway down

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
…; runLocal uses the vetted engine (gatewaystack-connect#1277)

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidcrowe
davidcrowe merged commit 5b61533 into main Sep 21, 2026
2 checks passed
@davidcrowe
davidcrowe deleted the feat/uninstall-floor branch September 21, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant