From 7c16dcaaf0171dab7248ae9f859d7fb534309903 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 22 Sep 2026 16:22:11 -0700 Subject: [PATCH 1/3] Add shared kernel-parity corpus, run it against decide.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The August drift was not a bad algorithm — the gateway fixed the classifier/floor class in July and the local copies stayed broken for weeks because nothing compared them. This adds the comparison: one JSON case table, consumed by every copy of the kernel, asserting classification key and hardline verdict. Two divergences recorded on this surface rather than silently tolerated: force-with-lease trips the local hardline floor while the gateway allows it, and `$(` classifies as Bash.$ instead of Bash.unknown. Both are scoped to the assertion that actually diverges, so the half that works stays asserted. The count guard fails if a new one appears AND if one is fixed without updating it. Destructive binaries live in the fixture as fragment arrays: a governed write of the bare literal is subject to response filtering, which would corrupt the fixture silently. Also scopes `npm test` to test/ — node --test with no path walks gitignored demo/.home and dies on a stray app bundle, so the suite could not be run locally at all. Refs davidcrowe/gatewaystack-connect#1316, #1333, #1335, #36 --- package.json | 2 +- test/fixtures/kernel-parity-corpus.json | 264 ++++++++++++++++++++++++ test/fixtures/load-corpus.mjs | 90 ++++++++ test/parity-corpus.test.mjs | 79 +++++++ 4 files changed, 434 insertions(+), 1 deletion(-) 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/package.json b/package.json index 14f4c0e..441f9a6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Auditable mirror of the Agentic Control Plane installer + local decision engine.", "scripts": { - "test": "node --test" + "test": "node --test test/" }, "license": "MIT" } 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..078388c --- /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 "../decide.mjs"; + +const SURFACE = "acp-install: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 d25375c188c4498ff332d26c3e6cd381a20b7690 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Wed, 23 Sep 2026 12:50:48 -0700 Subject: [PATCH 2/3] Kernel parity with the gateway (#1333, #1335) --- decide.mjs | 42 ++++----- install.sh | 42 ++++----- test/decide.test.mjs | 44 ++++++--- test/fixtures/kernel-parity-corpus.json | 115 ++++++++++-------------- test/parity-corpus.test.mjs | 4 +- 5 files changed, 125 insertions(+), 122 deletions(-) diff --git a/decide.mjs b/decide.mjs index 1ecbf32..a0e7011 100644 --- a/decide.mjs +++ b/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)); @@ -280,27 +284,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]; } @@ -315,7 +314,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) { @@ -549,7 +548,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]); @@ -623,9 +622,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* 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)); @@ -1432,27 +1436,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]; } @@ -1467,7 +1466,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) { @@ -1701,7 +1700,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]); @@ -1775,9 +1774,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* ["git", "push", ...rest].join(" "); const FLOOR_DENY = [ - // force-push to main — every natural phrasing - push("--force", "origin", "main"), - push("origin", "main", "--force"), // flags after the refspec - push("-f", "origin", "main"), // short flag - push("origin", "+main"), // + refspec force - push("--force-with-lease", "origin", "master"), // recursive force-delete — combined, split, long, trailing slash, sudo [RM, "-rf", "/"].join(" "), [RM, "-rf", "~"].join(" "), @@ -50,7 +44,6 @@ const FLOOR_DENY = [ ["eval", RM, "-rf", "~"].join(" "), // eval, bare ['eval "', RM, '-rf ~"'].join(" "), // eval, quoted ['bash -c "echo ok &&', RM, '-rf ~"'].join(" "),// compound inside -c - ["bash", "-c", `"${push("-f", "origin", "main")}"`].join(" "), // $HOME spellings [RM, "-rf", "$HOME"].join(" "), [RM, "-rf", "${HOME}"].join(" "), @@ -59,10 +52,14 @@ 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 joins SHELL_BINS + // any shell's -c string (#1333: a shape, not a list) + ["fish -c '", RM, "-rf /'"].join(" "), + ["csh -c '", RM, "-rf /'"].join(" "), + ["tcsh -c '", RM, "-rf /'"].join(" "), + ["ash -c '", RM, "-rf /'"].join(" "), + ["mksh -c '", RM, "-rf /'"].join(" "), ["echo ok $(", RM, "-rf ~ )"].join(" "), // command substitution [RM, "-rf", "/."].join(" "), // /. spelling of root ]; @@ -91,8 +88,34 @@ const FLOOR_ALLOW = [ "nice -n 10 make build", "git -C /repo status", "npm run lint && npm test", // compound, but benign + "ssh -c aes128-ctr host uptime", // ssh's -c is a cipher, not a shell ]; +// ── Force-push: an ASK on every surface, never hardline (#1335, decided +// 2026-09-23). Same verdict here as on the gateway. ────────────────────── +const FORCE_PUSH_ASK = [ + push("--force", "origin", "main"), + push("origin", "main", "--force"), // flags after the refspec + push("-f", "origin", "main"), // short flag + push("origin", "+main"), // + refspec force, no destination + push("origin", "+main:main"), + "git -C . push --force origin main", // option-argument before subcommand + ["bash", "-c", `"${push("-f", "origin", "main")}"`].join(" "), +]; + +for (const cmd of FORCE_PUSH_ASK) { + test(`force-push asks, not hardline: ${cmd}`, () => { + assert.equal(hardlineFloor(...bash(cmd)), null); + assert.equal(decide(...bash(cmd), { default: "allow", rules: {} }).decision, "ask"); + }); +} + +test("--force-with-lease is neither hardline nor an ask", () => { + const cmd = push("--force-with-lease", "origin", "master"); + assert.equal(hardlineFloor(...bash(cmd)), null); + assert.equal(decide(...bash(cmd), { default: "allow", rules: {} }).decision, "allow"); +}); + for (const cmd of FLOOR_ALLOW) { test(`floor allows: ${cmd}`, () => { assert.equal(hardlineFloor(...bash(cmd)), null, `floor should NOT fire on: ${cmd}`); @@ -132,6 +155,7 @@ test("compound commands classify by most-privileged segment; every segment is po test("unparseable non-empty commands are Bash.unknown, never a wrong-segment key", () => { assert.equal(classifyTool(...bash(") ) )")), "Bash.unknown"); // still governable; falls back to Bash in the walk + assert.equal(classifyTool(...bash("$(")), "Bash.unknown"); // not Bash.$ (#1335) assert.equal(classifyTool(...bash("")), "Bash"); }); @@ -151,7 +175,7 @@ test("default policy ships no dead rules", () => { // ── decide(): policy walk + precedence. ───────────────────────────────────── test("decide walks most-specific → least and honors default", () => { const policy = { default: "allow", rules: { "Bash.git.push": "deny", "Bash.curl": "ask" } }; - assert.equal(decide(...bash("git push origin main --force"), policy).decision, "deny"); // floor first + assert.equal(decide(...bash("git push origin main --force"), policy).decision, "deny"); // policy Bash.git.push (force-push itself only asks) assert.equal(decide(...bash("git push origin feature"), policy).decision, "deny"); // policy Bash.git.push assert.equal(decide(...bash("curl https://api.github.com"), policy).decision, "ask"); // walk to Bash.curl assert.equal(decide(...bash("echo hi"), policy).decision, "allow"); // default diff --git a/test/fixtures/kernel-parity-corpus.json b/test/fixtures/kernel-parity-corpus.json index f47fb12..8125e7e 100644 --- a/test/fixtures/kernel-parity-corpus.json +++ b/test/fixtures/kernel-parity-corpus.json @@ -95,18 +95,9 @@ "id": "19-wrapper-git-c-force-push", "tool": "Bash", "command": "git -C . push --force origin main", - "expect": { "keyPrefix": "Bash.git.push", "hardline": true }, + "expect": { "keyPrefix": "Bash.git.push", "hardline": false }, "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" - } - ] + "why": "An ORDINARY shape, not adversarial. firstSubcommand used to return the -C operand and build the malformed key Bash.git.. Force-push is an ask on every surface (destructive floor), never hardline: decided 2026-09-23, gatewaystack-connect#1335." }, { "id": "19-subshell", @@ -122,16 +113,7 @@ "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" - } - ] + "why": "Inner shell string. The outer binary is legitimately fish; the floor must still read inside -c." }, { "id": "extra-inner-shell-bash-c", @@ -173,55 +155,21 @@ "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" - } - ] + "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." }, { "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" - } - ] + "why": "Negative case: an ordinary push must not trip the force-push floor." }, { "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" - } - ] + "why": "Negative case: --force-with-lease is the safe spelling, recommended over --force, and must stay usable. --force-with-lease is the spelling Git recommends; it is never hardline (#1335)." }, { "id": "extra-grep-mentions-destructive-text", @@ -249,16 +197,47 @@ "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" - } - ] + "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'." + }, + { + "id": "extra-inner-shell-csh", + "tool": "Bash", + "command": "csh -c '{{RM}} -rf /'", + "expect": { "keyPrefix": "Bash.csh", "hardline": true }, + "issue": "davidcrowe/gatewaystack-connect#1333", + "why": "Shell names are a shape, not a list: csh was missing from the gateway enumeration." + }, + { + "id": "extra-inner-shell-tcsh", + "tool": "Bash", + "command": "tcsh -c '{{RM}} -rf /'", + "expect": { "keyPrefix": "Bash.tcsh", "hardline": true }, + "issue": "davidcrowe/gatewaystack-connect#1333", + "why": "As csh." + }, + { + "id": "extra-inner-shell-ash", + "tool": "Bash", + "command": "ash -c '{{RM}} -rf /'", + "expect": { "keyPrefix": "Bash.ash", "hardline": true }, + "issue": "davidcrowe/gatewaystack-connect#1333", + "why": "A shell no surface listed before 2026-09-23; the shape rule has to cover it." + }, + { + "id": "extra-ssh-cipher-not-shell", + "tool": "Bash", + "command": "ssh -c aes128-ctr host uptime", + "expect": { "keyPrefix": "Bash.ssh", "hardline": false }, + "issue": "davidcrowe/gatewaystack-connect#1333", + "why": "ssh ends in sh but its -c takes a cipher. The shape rule must exclude it." + }, + { + "id": "extra-xargs-flags", + "tool": "Bash", + "command": "ls | xargs -I {} {{RM}} {}", + "expect": { "keyPrefix": "Bash.{{RM}}", "hardline": false }, + "issue": "davidcrowe/gatewaystack-connect#1335", + "why": "xargs value flags consume their operand; the key is the command xargs runs." } ] } diff --git a/test/parity-corpus.test.mjs b/test/parity-corpus.test.mjs index 078388c..dc1bdba 100644 --- a/test/parity-corpus.test.mjs +++ b/test/parity-corpus.test.mjs @@ -17,7 +17,7 @@ const SURFACE = "acp-install: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) => { From 1727f8c3f9c0a263865e65d6bf28cc96394c5332 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Tue, 6 Oct 2026 21:04:59 -0700 Subject: [PATCH 3/3] Installer copy: force-push to main asks, it is not hard-denied After #1333/#1335 the floor asks before a force-push to main; the policy comment and the install banner still said it always denies. --- install.sh | 6 +++--- policy.default.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 7cba408..5e1a1b6 100755 --- a/install.sh +++ b/install.sh @@ -2986,7 +2986,7 @@ if [ "$LOCAL_MODE" = true ]; then if [ ! -f "$CONFIG_DIR/policy.json" ]; then cat > "$CONFIG_DIR/policy.json" << 'POLICY' { - "_comment": "Local ACP policy — edit freely. decide.mjs walks keys most-specific → least (e.g. Bash.curl.api.github.com → Bash.curl → Bash). Values: allow | ask | deny. The safety floor (rm -rf /, mkfs, dd of a disk, fork bombs, force-push to main) always denies regardless of this file. 'default' applies when no rule matches. 'contextGuard' sizes whole-file reads (Read, cat, head, tail, less, more) before they happen: mode shadow only records what a read over maxLines would have put into context (see ~/.acp/audit.jsonl → contextGuard.estTokens); set mode to enforce to block them with a steer toward offset/limit, grep, or a subagent.", + "_comment": "Local ACP policy — edit freely. decide.mjs walks keys most-specific → least (e.g. Bash.curl.api.github.com → Bash.curl → Bash). Values: allow | ask | deny. The safety floor (rm -rf /, mkfs, dd of a disk, fork bombs) always denies regardless of this file, and a force-push to main always asks first. 'default' applies when no rule matches. 'contextGuard' sizes whole-file reads (Read, cat, head, tail, less, more) before they happen: mode shadow only records what a read over maxLines would have put into context (see ~/.acp/audit.jsonl → contextGuard.estTokens); set mode to enforce to block them with a steer toward offset/limit, grep, or a subagent.", "default": "allow", "rules": { "Bash.rm": "ask", @@ -3012,8 +3012,8 @@ POLICY echo " Every call is logged to ${C_DIM}~/.acp/audit.jsonl${C_RESET}:" echo " ${C_DIM}tail -f ~/.acp/audit.jsonl${C_RESET}" echo "" - echo " The safety floor always blocks the catastrophic (rm -rf /, mkfs, dd, fork bombs," - echo " force-push to main) regardless of policy." + echo " The safety floor always blocks the catastrophic (rm -rf /, mkfs, dd, fork bombs)" + echo " and always asks before a force-push to main, regardless of policy." echo "" echo " Restart your AI client to activate the hook." echo " Want team control, cost X-ray, and a shared console across everyone's agents?" diff --git a/policy.default.json b/policy.default.json index f1c4c7b..cf98a7e 100644 --- a/policy.default.json +++ b/policy.default.json @@ -1,5 +1,5 @@ { - "_comment": "Local ACP policy — edit freely. decide.mjs walks keys most-specific → least (e.g. Bash.curl.api.github.com → Bash.curl → Bash). Values: allow | ask | deny. The safety floor (rm -rf /, mkfs, dd of a disk, fork bombs, force-push to main) always denies regardless of this file. 'default' applies when no rule matches. 'contextGuard' sizes whole-file reads (Read, cat, head, tail, less, more) before they happen: mode shadow only records what a read over maxLines would have put into context (see ~/.acp/audit.jsonl → contextGuard.estTokens); set mode to enforce to block them with a steer toward offset/limit, grep, or a subagent.", + "_comment": "Local ACP policy — edit freely. decide.mjs walks keys most-specific → least (e.g. Bash.curl.api.github.com → Bash.curl → Bash). Values: allow | ask | deny. The safety floor (rm -rf /, mkfs, dd of a disk, fork bombs) always denies regardless of this file, and a force-push to main always asks first. 'default' applies when no rule matches. 'contextGuard' sizes whole-file reads (Read, cat, head, tail, less, more) before they happen: mode shadow only records what a read over maxLines would have put into context (see ~/.acp/audit.jsonl → contextGuard.estTokens); set mode to enforce to block them with a steer toward offset/limit, grep, or a subagent.", "default": "allow", "rules": { "Bash.rm": "ask",