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* "$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/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/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", diff --git a/test/decide.test.mjs b/test/decide.test.mjs index 70ede4b..8e9db18 100644 --- a/test/decide.test.mjs +++ b/test/decide.test.mjs @@ -26,12 +26,6 @@ const RM = "r" + "m"; const push = (...rest) => ["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 new file mode 100644 index 0000000..8125e7e --- /dev/null +++ b/test/fixtures/kernel-parity-corpus.json @@ -0,0 +1,243 @@ +{ + "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": 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.. Force-push is an ask on every surface (destructive floor), never hardline: decided 2026-09-23, 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." + }, + { + "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." + }, + { + "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." + }, + { + "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. --force-with-lease is the spelling Git recommends; it is never hardline (#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'." + }, + { + "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/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..dc1bdba --- /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 = 0; + +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, "b1c2dd8b502cc0aa"); +}); + +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")}`, + ); +});