From d58419f3ea48577470b96fbd78123aa24cd53b54 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 16:22:44 -0700 Subject: [PATCH 1/2] Run the shared kernel-parity corpus against bin/decide.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This copy of the kernel had no cross-surface guard: it ships to every Claude Code user, and nothing compared its verdicts to the canonical local kernel or to the gateway. That is the gap that let the August drift live for weeks. Vendors the corpus from acp-install and asserts the same 24 cases. Result matches the canonical kernel exactly — same passes, same two recorded divergences, same fingerprint — which is behavioural evidence the two copies agree without diffing them. Refs davidcrowe/gatewaystack-connect#1316, agentic-control-plane/acp-install#36 --- test/fixtures/kernel-parity-corpus.json | 264 ++++++++++++++++++++++++ test/fixtures/load-corpus.mjs | 90 ++++++++ test/parity-corpus.test.mjs | 79 +++++++ 3 files changed, 433 insertions(+) create mode 100644 test/fixtures/kernel-parity-corpus.json create mode 100644 test/fixtures/load-corpus.mjs create mode 100644 test/parity-corpus.test.mjs diff --git a/test/fixtures/kernel-parity-corpus.json b/test/fixtures/kernel-parity-corpus.json new file mode 100644 index 0000000..f47fb12 --- /dev/null +++ b/test/fixtures/kernel-parity-corpus.json @@ -0,0 +1,264 @@ +{ + "corpus": "acp-kernel-parity", + "version": 1, + "canonicalHome": "agentic-control-plane/acp-install:test/fixtures/kernel-parity-corpus.json", + "purpose": "One shared table of (tool, command) -> expected classification prefix and expected hardline-floor verdict, run against EVERY copy of the decision kernel. The August 2026 failure was not a bad algorithm, it was one-way drift: the gateway fixed this class in gatewaystack-connect#516 in July and the local copies stayed broken for about four weeks because nothing compared them. This corpus is the comparison.", + "surfaces": [ + "acp-install:decide.mjs", + "claude-code-acp-plugin:bin/decide.mjs", + "gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts" + ], + "assertions": { + "keyPrefix": "classifyTool(tool, input) MUST return this string or a more specific key beneath it (dot-separated). This is the #18 invariant: the classified key follows the most-privileged segment, not the first one.", + "hardline": "true means the hardline floor MUST deny this regardless of configured policy. This is the #19 invariant: wrapper shapes do not get you under the floor.", + "policyDeny": "true means: with policy {default: allow, rules: {: deny}}, the decision MUST be deny. Local surfaces assert this via decide(); the gateway's equivalent needs tenant context, so v1 asserts keyPrefix and hardline there and leaves the policy walk to the gateway's own suite." + }, + "knownDivergencesSchema": "Each entry names the surfaces and the specific assertions that do not meet the expectation, with a verified reason. Divergences are per-assertion and per-surface on purpose: skipping a whole case would hide a future regression in the half that currently works. A NEW divergence fails CI; FIXING one also fails CI until the count is lowered. Neither can be quietly forgotten.", + "tokens": { + "RM": ["r", "m"], + "note": "Destructive binaries are stored as fragment arrays and joined by the loader. Writing the bare literal into a file is subject to response filtering and redaction on governed writes, which would silently corrupt the fixture. Do not inline them." + }, + "cases": [ + { + "id": "18-gcloud-direct", + "tool": "Bash", + "command": "gcloud sql instances delete x --quiet", + "expect": { "keyPrefix": "Bash.gcloud", "hardline": false, "policyDeny": true }, + "issue": "acp-install#18", + "why": "Baseline: the plain spelling must hit the policy rule." + }, + { + "id": "18-gcloud-compound-prefix", + "tool": "Bash", + "command": "which gcloud && gcloud sql instances delete x --quiet", + "expect": { "keyPrefix": "Bash.gcloud", "hardline": false, "policyDeny": true }, + "issue": "acp-install#18", + "why": "THE original bypass. Classifying by the FIRST segment yields Bash.which and allows the delete." + }, + { + "id": "18-rm-direct", + "tool": "Bash", + "command": "{{RM}} -rf /tmp/demo", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": false, "policyDeny": true }, + "issue": "acp-install#18", + "why": "Baseline for the policy rule; /tmp is not a floor path." + }, + { + "id": "18-rm-compound-prefix", + "tool": "Bash", + "command": "true && {{RM}} -rf /tmp/demo", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": false, "policyDeny": true }, + "issue": "acp-install#18", + "why": "Same bypass with a no-op prefix instead of a lookup." + }, + { + "id": "19-floor-root", + "tool": "Bash", + "command": "{{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "issue": "acp-install#19", + "why": "The floor itself." + }, + { + "id": "19-floor-root-compound", + "tool": "Bash", + "command": "true && {{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "issue": "acp-install#19", + "why": "Floor must scan every segment, not just the first." + }, + { + "id": "19-wrapper-sudo-u", + "tool": "Bash", + "command": "sudo -u root {{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "issue": "acp-install#19", + "why": "Wrapper with a POSITIONAL operand. The original parser ate flags but not the operand, then treated 'root' as the binary." + }, + { + "id": "19-wrapper-timeout", + "tool": "Bash", + "command": "timeout 5 {{RM}} -rf ~/Documents", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": false, "policyDeny": true }, + "issue": "acp-install#19", + "why": "timeout takes a bare number operand. Home-dir recursive delete is policy territory here, not the root floor." + }, + { + "id": "19-wrapper-nice", + "tool": "Bash", + "command": "nice -n 10 {{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "issue": "acp-install#19", + "why": "Option-argument form: -n consumes 10, the binary is two tokens further on." + }, + { + "id": "19-wrapper-git-c-force-push", + "tool": "Bash", + "command": "git -C . push --force origin main", + "expect": { "keyPrefix": "Bash.git.push", "hardline": true }, + "issue": "acp-install#19", + "why": "An ORDINARY shape, not adversarial. firstSubcommand used to return the -C operand and build the malformed key Bash.git..", + "knownDivergences": [ + { + "assertions": ["keyPrefix", "hardline"], + "surfaces": ["gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts"], + "detail": "Two deliberate differences, both verified 2026-09-22. (1) The gateway keys git coarsely as Bash.git and distinguishes push variants by hashing params, not by key depth — see approvalGrantCanonical(\"Bash.git\", …) at apps/tenant-gateway/src/govern/approvals.test.ts:474. (2) The gateway puts force-push at ask level via detectDestructive, not at hardline, so detectHardline returns null. Neither is a miss; both mean a policy written against one surface does not behave identically on the other.", + "direction": "gateway is coarser on the key and lower-severity on the floor", + "issue": "davidcrowe/gatewaystack-connect#1335" + } + ] + }, + { + "id": "19-subshell", + "tool": "Bash", + "command": "( {{RM}} -rf / )", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "issue": "acp-install#19", + "why": "Parenthesised subshell." + }, + { + "id": "19-inner-shell-fish", + "tool": "Bash", + "command": "fish -c '{{RM}} -rf /'", + "expect": { "keyPrefix": "Bash.fish", "hardline": true }, + "issue": "acp-install#19", + "why": "Inner shell string. The outer binary is legitimately fish; the floor must still read inside -c.", + "knownDivergences": [ + { + "assertions": ["hardline"], + "surfaces": ["gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts"], + "detail": "VERIFIED 2026-09-22 on the gateway: detectHardline returns null for fish, csh and tcsh, while bash and zsh are denied. Cause: collectExecutedLiterals in riskClassifier.ts matches (ba|z|da|k)?sh, powershell and pwsh, and does not include fish, csh or tcsh. NOT a silent allow — detectDestructive still fires with 'recursive delete outside the working directory: /', so this is a SEVERITY DOWNGRADE from an unconditional hardline deny to an ask that policy participates in. Same launder class as the 2026-09-06 pentest fix for sh -c, which enumerated shells and missed these three.", + "direction": "gateway is WEAKER than the local kernel here, and the gateway is the enforcing surface for every hook 0.12+ install", + "issue": "davidcrowe/gatewaystack-connect#1333" + } + ] + }, + { + "id": "extra-inner-shell-bash-c", + "tool": "Bash", + "command": "bash -c '{{RM}} -rf /'", + "expect": { "keyPrefix": "Bash.bash", "hardline": true }, + "why": "Same shape via bash. Regression guard, not a reported bug." + }, + { + "id": "extra-inner-shell-sh-c", + "tool": "Bash", + "command": "sh -c \"{{RM}} -rf /\"", + "expect": { "keyPrefix": "Bash.sh", "hardline": true }, + "why": "Double-quoted inner shell string." + }, + { + "id": "extra-env-prefix", + "tool": "Bash", + "command": "env FOO=1 {{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "why": "env with an assignment operand before the binary." + }, + { + "id": "extra-bare-assignment-prefix", + "tool": "Bash", + "command": "FOO=1 {{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "why": "Shell assignment prefix with no env binary at all." + }, + { + "id": "extra-path-qualified", + "tool": "Bash", + "command": "/bin/{{RM}} -rf /", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": true }, + "why": "Absolute path spelling must canonicalise to the same binary." + }, + { + "id": "extra-pipeline-tail", + "tool": "Bash", + "command": "echo /tmp/x | xargs {{RM}} -rf", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": false }, + "why": "The kernel reaches THROUGH xargs to the real binary, so a policy rule on the delete binary still catches it. Authored expecting Bash.xargs; the local kernel was more conservative and better.", + "knownDivergences": [ + { + "assertions": ["keyPrefix"], + "surfaces": ["gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts"], + "detail": "The gateway keeps the key as Bash.xargs and attaches a high risk tier to the xargs-plus-delete combination instead of reclassifying — a deliberate design, documented at riskClassifier.ts:891-894 as deletion-by-indirection (#304, found via Hermes). Not a miss. The consequence is still real: a user rule on the delete binary catches this locally and does not match on the gateway, which reaches it through the risk-tier path instead.", + "direction": "same risk, different representation; policy rules are not portable between the two", + "issue": "davidcrowe/gatewaystack-connect#1335" + } + ] + }, + { + "id": "extra-git-push-plain", + "tool": "Bash", + "command": "git push origin main", + "expect": { "keyPrefix": "Bash.git.push", "hardline": false }, + "why": "Negative case: an ordinary push must not trip the force-push floor.", + "knownDivergences": [ + { + "assertions": ["keyPrefix"], + "surfaces": ["gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts"], + "detail": "The gateway keys git as Bash.git without subcommand depth; the local kernel gives Bash.git.push and asserts that granularity in test/decide.test.mjs. A rule written as Bash.git.push matches locally and never matches on the gateway.", + "direction": "gateway key is coarser; rules are not portable", + "issue": "davidcrowe/gatewaystack-connect#1335" + } + ] + }, + { + "id": "extra-git-push-force-with-lease", + "tool": "Bash", + "command": "git push --force-with-lease origin main", + "expect": { "keyPrefix": "Bash.git.push", "hardline": false }, + "why": "Negative case: --force-with-lease is the safe spelling, recommended over --force, and must stay usable.", + "knownDivergences": [ + { + "assertions": ["hardline"], + "surfaces": ["acp-install:decide.mjs", "claude-code-acp-plugin:bin/decide.mjs"], + "detail": "The local kernel HARD-DENIES this. decide.mjs gitForcePushFloor includes args.includes(\"--force-with-lease\") in its force test, so the safe spelling trips the hardline floor, which policy cannot override. The gateway does the opposite and returns null for this exact command, asserted by its own test at apps/tenant-gateway/src/govern/destructiveFloor.test.ts:26. Same command: allowed server-side, hard-denied locally.", + "direction": "local is MORE restrictive than the gateway, and blocks the operation Git documentation recommends over --force", + "issue": "davidcrowe/gatewaystack-connect#1335" + }, + { + "assertions": ["keyPrefix"], + "surfaces": ["gatewaystack-connect:apps/tenant-gateway/src/govern/hookGovernance.ts"], + "detail": "Gateway keys git as Bash.git without subcommand depth. Same cause as extra-git-push-plain.", + "direction": "gateway key is coarser; rules are not portable", + "issue": "davidcrowe/gatewaystack-connect#1335" + } + ] + }, + { + "id": "extra-grep-mentions-destructive-text", + "tool": "Bash", + "command": "grep -rn '{{RM}} -rf /' src/", + "expect": { "keyPrefix": "Bash.grep", "hardline": false }, + "why": "Negative case: a destructive string appearing as DATA inside a search must not deny. This is the false-positive direction, and it is the one that makes people uninstall." + }, + { + "id": "extra-echo-mentions-destructive-text", + "tool": "Bash", + "command": "echo \"{{RM}} -rf /\"", + "expect": { "keyPrefix": "Bash.echo", "hardline": false }, + "why": "Negative case: quoted data, not execution." + }, + { + "id": "extra-empty-command", + "tool": "Bash", + "command": "", + "expect": { "keyPrefix": "Bash", "hardline": false }, + "why": "Degenerate input must not throw and must not silently become a wrong key." + }, + { + "id": "extra-unparseable", + "tool": "Bash", + "command": "$(", + "expect": { "keyPrefix": "Bash.unknown", "hardline": false }, + "why": "Unparseable but non-empty must land on the explicit unknown key, never on a junk key. The repo states this contract in test/decide.test.mjs: 'unparseable non-empty commands are Bash.unknown, never a wrong-segment key'.", + "knownDivergences": [ + { + "assertions": ["keyPrefix"], + "surfaces": ["acp-install:decide.mjs", "claude-code-acp-plugin:bin/decide.mjs"], + "detail": "Returns the junk key Bash.$ instead of Bash.unknown. The existing contract test only covers ') ) )', which does land on Bash.unknown, so the stated contract is stronger than the code delivers. Low severity: a junk key falls through the candidate walk to Bash and then to the default, so the call is still governable — but it is not the explicit unknown key a policy can target.", + "direction": "neither more nor less restrictive; a key nobody can write a rule against", + "issue": "davidcrowe/gatewaystack-connect#1335" + } + ] + } + ] +} diff --git a/test/fixtures/load-corpus.mjs b/test/fixtures/load-corpus.mjs new file mode 100644 index 0000000..db85ab5 --- /dev/null +++ b/test/fixtures/load-corpus.mjs @@ -0,0 +1,90 @@ +// Loader for the shared kernel-parity corpus. +// +// The corpus stores destructive binary names as fragment arrays (tokens) and +// references them as {{NAME}} inside commands and expected keys. Governed +// writes of the bare literals are subject to response filtering, which would +// silently corrupt the fixture — so the literal never appears in the file. +// +// Keep this loader behaviourally identical everywhere the corpus is consumed. + +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function expandTokens(s, tokens) { + return String(s).replace(/\{\{([A-Z_]+)\}\}/g, (whole, name) => { + const frags = tokens[name]; + if (!Array.isArray(frags)) return whole; + return frags.join(""); + }); +} + +export function loadCorpus(path = join(HERE, "kernel-parity-corpus.json")) { + const raw = readFileSync(path, "utf8"); + const doc = JSON.parse(raw); + const tokens = doc.tokens || {}; + + const cases = doc.cases.map((c) => ({ + ...c, + command: expandTokens(c.command, tokens), + expect: { + ...c.expect, + keyPrefix: expandTokens(c.expect.keyPrefix, tokens), + }, + })); + + // Fingerprint of the case table only, so prose edits to the header do not + // churn it. Compared across repos to catch a vendored copy drifting. + const fingerprint = createHash("sha256") + .update(JSON.stringify(doc.cases)) + .digest("hex") + .slice(0, 16); + + return { version: doc.version, cases, fingerprint, raw: doc }; +} + +// A classified key satisfies the expectation if it IS the expected key or sits +// beneath it. "Bash.gcloud" is satisfied by "Bash.gcloud.sql"; it is not +// satisfied by "Bash.gcloudfoo". +export function keySatisfies(actual, expectedPrefix) { + if (actual === expectedPrefix) return true; + return String(actual).startsWith(expectedPrefix + "."); +} + +// True when this case carries a recorded divergence that names the given +// surface AND the given assertion. Surface names are the ones listed in the +// corpus `surfaces` array; assertion names are the keys of `assertions` in the +// corpus header ("keyPrefix", "hardline", "policyDeny"). +// +// Divergences are per-assertion on purpose. A case whose floor verdict diverges +// still has a classification we want asserted; skipping the whole case would +// hide a future regression in the half that currently works. +export function divergesHere(testCase, surface, assertion) { + return Boolean(divergenceFor(testCase, surface, assertion)); +} + +// The matching divergence entry, or undefined. +export function divergenceFor(testCase, surface, assertion) { + const list = testCase.knownDivergences; + if (!Array.isArray(list)) return undefined; + return list.find((d) => { + if (!Array.isArray(d.surfaces) || !d.surfaces.includes(surface)) return false; + if (!Array.isArray(d.assertions)) return true; // unscoped: whole case + return d.assertions.includes(assertion); + }); +} + +// Every divergence entry recorded against a surface, for the count guard. +export function divergencesOn(cases, surface) { + const out = []; + for (const c of cases) { + for (const d of c.knownDivergences || []) { + if (!Array.isArray(d.surfaces) || !d.surfaces.includes(surface)) continue; + for (const a of d.assertions || ["all"]) out.push({ id: c.id, assertion: a, ...d }); + } + } + return out; +} diff --git a/test/parity-corpus.test.mjs b/test/parity-corpus.test.mjs new file mode 100644 index 0000000..e77871e --- /dev/null +++ b/test/parity-corpus.test.mjs @@ -0,0 +1,79 @@ +// Runs the shared kernel-parity corpus against THIS repo's decide.mjs. +// +// The same corpus runs against claude-code-acp-plugin/bin/decide.mjs and +// against the gateway's classifier. A case that passes here and fails there is +// drift — which is exactly how acp-install#18/#19 survived about four weeks +// after gatewaystack-connect#516 fixed the same class server-side. +// +// Adding a case here without adding it to the other surfaces is not a fix. +// See davidcrowe/gatewaystack-connect#1316 and acp-install#36. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { loadCorpus, keySatisfies, divergenceFor, divergencesOn } from "./fixtures/load-corpus.mjs"; +import { classifyTool, hardlineFloor, decide } from "../bin/decide.mjs"; + +const SURFACE = "claude-code-acp-plugin:bin/decide.mjs"; +const { cases, fingerprint, version } = loadCorpus(); + +// Recorded divergences on THIS surface. Drive it to zero. +const EXPECTED_DIVERGENCES = 2; + +test("corpus loads, is non-trivial, and its fingerprint is stable", () => { + assert.equal(version, 1); + assert.ok(cases.length >= 24, `expected at least 24 cases, got ${cases.length}`); + // If this fails, the case table changed. That is allowed — update the value + // here AND in every other surface that vendors the corpus, in the same + // change, or the copies have drifted. + assert.equal(fingerprint, "1dceec2ee129bee3"); +}); + +const skipFor = (c, assertion) => { + const d = divergenceFor(c, SURFACE, assertion); + return d ? `known divergence (${d.issue}): ${d.detail}` : undefined; +}; + +for (const c of cases) { + test(`[${c.id}] classification: ${c.expect.keyPrefix}`, { skip: skipFor(c, "keyPrefix") }, () => { + const key = classifyTool(c.tool, { command: c.command }); + assert.ok( + keySatisfies(key, c.expect.keyPrefix), + `${c.id}: expected a key at or under "${c.expect.keyPrefix}", got "${key}"\n command: ${c.command}\n why: ${c.why}`, + ); + }); + + test(`[${c.id}] hardline floor: ${c.expect.hardline ? "DENY" : "no floor"}`, { skip: skipFor(c, "hardline") }, () => { + const floor = hardlineFloor(c.tool, { command: c.command }); + const denied = floor !== null && floor !== undefined && floor !== false; + assert.equal( + denied, + c.expect.hardline, + `${c.id}: hardline floor expected ${c.expect.hardline ? "DENY" : "no deny"}, got ${JSON.stringify(floor)}\n command: ${c.command}\n why: ${c.why}`, + ); + }); + + if (c.expect.policyDeny) { + test(`[${c.id}] policy deny on ${c.expect.keyPrefix}`, { skip: skipFor(c, "policyDeny") }, () => { + const policy = { default: "allow", rules: { [c.expect.keyPrefix]: "deny" } }; + const d = decide(c.tool, { command: c.command }, policy, {}); + assert.equal( + d.decision, + "deny", + `${c.id}: policy denying "${c.expect.keyPrefix}" must deny this command, got ${JSON.stringify(d)}\n command: ${c.command}\n why: ${c.why}`, + ); + }); + } +} + +test(`divergence count on ${SURFACE} is exactly ${EXPECTED_DIVERGENCES}`, () => { + const recorded = divergencesOn(cases, SURFACE); + const lines = recorded.map((d) => ` - ${d.id} [${d.assertion}] ${d.issue}: ${d.direction}`); + assert.equal( + recorded.length, + EXPECTED_DIVERGENCES, + `divergence count changed on ${SURFACE}.\n` + + `If you FIXED one, lower EXPECTED_DIVERGENCES and drop it from the corpus.\n` + + `If a NEW one appeared, that is a regression — do not raise the number to make this green.\n` + + `Currently recorded:\n${lines.join("\n")}`, + ); +}); From 1bb89715f16d8286a1dddc2e34c896a5e4329ea2 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Wed, 23 Sep 2026 12:49:00 -0700 Subject: [PATCH 2/2] Kernel parity with the gateway (#1333, #1335) --- bin/decide.mjs | 42 ++++----- test/fixtures/kernel-parity-corpus.json | 115 ++++++++++-------------- test/local-mode.test.mjs | 6 +- test/parity-corpus.test.mjs | 4 +- test/wrapper-bypass.test.mjs | 1 - 5 files changed, 73 insertions(+), 95 deletions(-) diff --git a/bin/decide.mjs b/bin/decide.mjs index dba6375..cda8322 100644 --- a/bin/decide.mjs +++ b/bin/decide.mjs @@ -172,10 +172,12 @@ const PRIVILEGED_BINS = new Set([ * unknown 1, privileged 2. Ties resolve to the EARLIEST unit. */ function privilegeRank(bin) { if (BENIGN_BINS.has(bin)) return 0; - if (PRIVILEGED_BINS.has(bin)) return 2; + if (PRIVILEGED_BINS.has(bin) || isShellBin(bin)) return 2; // every shell, not only the listed ones (#1333) return 1; } +const COMMAND_WORD_RE = /^(?:\[\[?|[\w.][\w.+-]*)$/; + /** Every governed unit of a Bash command: one per segment, plus the payload * of any `bash -c "…"` / `eval …` hand-off (recursed, capped), so neither a * benign prefix nor an interpreter hop hides a unit from policy. */ @@ -184,7 +186,9 @@ function commandUnits(cmd, depth = 0) { if (depth > 3) return units; for (const seg of splitSegments(cmd)) { const { bin, args } = parseCommand(seg); - if (!bin) continue; + // A residue like `$` from `$(` is not a command word, and must not name + // the call Bash.$ — no rule can be written against it (#1335). + if (!bin || !COMMAND_WORD_RE.test(bin)) continue; units.push({ bin, args, seg }); const inner = innerShellCommand(bin, args); if (inner) units.push(...commandUnits(inner, depth + 1)); @@ -298,27 +302,22 @@ function rmForceFloor(bin, args) { return null; } -/** git push that force-updates main/master (any flag order, -f or --force, or - * a +refspec). */ -function gitForcePushFloor(bin, args) { - if (bin !== "git") return null; - if (firstSubcommand(args) !== "push") return null; - const targetsMain = args.some((a) => /(^|[:+/])(main|master)$/.test(a)); - if (!targetsMain) return null; - const forceFlag = hasShortOrLongFlag(args, "f", "force") || args.includes("--force-with-lease"); - const plusRefspec = args.some((a) => /^\+/.test(a) && /(main|master)/.test(a)); - if (forceFlag || plusRefspec) return "force-push to main/master"; - return null; -} +// Force-push is not on this floor (#1335, decided 2026-09-23): it is an ask +// on every surface, via FORCE_PUSH_RE in the destructive floor, so the same +// push behaves the same locally and on the gateway. // Shells whose `-c ` argument is itself a command line: recurse the // floor into it so `bash -c "rm -rf ~"` can't launder past token inspection. -const SHELL_BINS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]); +// A shape, not a list (#1333): any short word ending in "sh" — ash, mksh and +// whatever comes next — except ssh, whose -c takes a cipher. +function isShellBin(bin) { + return bin !== "ssh" && /^[a-z]{0,3}sh$/.test(bin); +} /** If this command hands a string to another interpreter (`bash -c '…'`, * `eval …`), return that inner command line; else undefined. */ function innerShellCommand(bin, args) { - if (SHELL_BINS.has(bin)) { + if (isShellBin(bin)) { for (let i = 0; i < args.length; i++) { if (/^-[a-z]*c[a-z]*$/i.test(args[i])) return args[i + 1]; } @@ -333,7 +332,7 @@ function tokenFloorScan(cmd, depth = 0) { if (depth > 3) return null; for (const seg of splitSegments(cmd)) { const { bin, args } = parseCommand(seg); - const hit = rmForceFloor(bin, args) || gitForcePushFloor(bin, args); + const hit = rmForceFloor(bin, args); if (hit) return hit; const inner = innerShellCommand(bin, args); if (inner) { @@ -567,7 +566,7 @@ export function stripDataHeredocs(cmd) { const body = lines.slice(i + 1, end); out.push(line); const { bin, args } = parseCommand(line.slice(0, m.index)); - const shellDashC = SHELL_BINS.has(bin) && args.some((a) => /^-[a-z]*c[a-z]*$/i.test(a)); + const shellDashC = isShellBin(bin) && args.some((a) => /^-[a-z]*c[a-z]*$/i.test(a)); if (INTERPRETER_BINS.has(bin) && !shellDashC) out.push(...body); else if (!quoted) { const subs = body.join("\n").match(/\$\([^)]*\)|`[^`]*`/g); if (subs) out.push(subs.join(" ")); } if (end < lines.length) out.push(lines[end]); @@ -641,9 +640,10 @@ export function sqlPayloads(cmd) { return out.map((x) => x.trim()).filter(Boolean); } -const FORCE_PUSH_RE = /\bgit\b[^|;&\n]*\bpush\b[^|;&\n]*(?:\s--force(?!-with-lease|-if-includes)\b|\s-[a-eg-zA-Z]*f[a-zA-Z]*(?=\s|$)|\s\+[^\s:]+:)/; -const PIPE_TO_SHELL_RE = /\b(?:curl|wget)\b[^|;&\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\S*\/)?(?:ba|z|da|k)?sh\b/; -const SHELL_OF_DOWNLOAD_RE = /\b(?:ba|z|da|k)?sh\s+(?:-[a-zA-Z]+\s+)*(?:-c\s+["']?\$\(\s*(?:curl|wget)\b|<\s* { +test("floor: force-push to main asks (#1335: destructive floor, not hardline)", () => { const out = hook(pre("git push --force origin main")); - assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.equal(out.hookSpecificOutput.permissionDecision, "ask"); assert.match(out.hookSpecificOutput.permissionDecisionReason, /force-push/); }); @@ -173,7 +173,7 @@ test("ACP_LOCAL=1 with no policy file: floor still active, default allow for the copyFileSync(DECIDE, join(bare, ".acp", "decide.mjs")); try { const denied = spawnSync(process.execPath, [GOVERN], { - input: JSON.stringify(pre("git push -f origin main")), + input: JSON.stringify(pre("mkfs.ext4 /dev/sda1")), encoding: "utf8", env: { HOME: bare, PATH: process.env.PATH, ACP_LOCAL: "1" }, timeout: 15000, diff --git a/test/parity-corpus.test.mjs b/test/parity-corpus.test.mjs index e77871e..861b708 100644 --- a/test/parity-corpus.test.mjs +++ b/test/parity-corpus.test.mjs @@ -17,7 +17,7 @@ const SURFACE = "claude-code-acp-plugin:bin/decide.mjs"; const { cases, fingerprint, version } = loadCorpus(); // Recorded divergences on THIS surface. Drive it to zero. -const EXPECTED_DIVERGENCES = 2; +const EXPECTED_DIVERGENCES = 0; test("corpus loads, is non-trivial, and its fingerprint is stable", () => { assert.equal(version, 1); @@ -25,7 +25,7 @@ test("corpus loads, is non-trivial, and its fingerprint is stable", () => { // If this fails, the case table changed. That is allowed — update the value // here AND in every other surface that vendors the corpus, in the same // change, or the copies have drifted. - assert.equal(fingerprint, "1dceec2ee129bee3"); + assert.equal(fingerprint, "b1c2dd8b502cc0aa"); }); const skipFor = (c, assertion) => { diff --git a/test/wrapper-bypass.test.mjs b/test/wrapper-bypass.test.mjs index 38ae980..f9f2954 100644 --- a/test/wrapper-bypass.test.mjs +++ b/test/wrapper-bypass.test.mjs @@ -23,7 +23,6 @@ const FLOOR_DENY = [ ["timeout", "5", RM, "-rf", "~"].join(" "), // timeout's DURATION operand ["nice", "-n", "10", RM, "-rf", "/"].join(" "), // nice's -n N operand ["nohup", "timeout", "2", RM, "-rf", "/"].join(" "), // stacked wrappers - "git -C . push --force origin main", // option-argument before subcommand ["(", RM, "-rf", "/", ")"].join(" "), // subshell ["{", RM, "-rf", "/;", "}"].join(" "), // brace group ["fish -c '", RM, "-rf /'"].join(" "), // fish is a SHELL_BIN