Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions bin/decide.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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));
Expand Down Expand Up @@ -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 <string>` 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];
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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*<?\s*\(\s*(?:curl|wget)\b)/;
// `+main` forces as surely as `+main:main` (#1335).
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:]+(?::|(?=\s|$)))/;
const PIPE_TO_SHELL_RE = /\b(?:curl|wget)\b[^|;&\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\S*\/)?(?!ssh\b)[a-z]{0,3}sh\b/;
const SHELL_OF_DOWNLOAD_RE = /\b(?!ssh\b)[a-z]{0,3}sh\s+(?:-[a-zA-Z]+\s+)*(?:-c\s+["']?\$\(\s*(?:curl|wget)\b|<\s*<?\s*\(\s*(?:curl|wget)\b)/;
const TMP_PATH_RE = /^(?:\/tmp|\/private\/tmp|\/var\/folders|\/var\/tmp|\$\{?TMPDIR\}?)(?:\/|$)/;

/** `rm -r` whose target is absolute, home-relative, parent-relative, or a
Expand Down
243 changes: 243 additions & 0 deletions test/fixtures/kernel-parity-corpus.json
Original file line number Diff line number Diff line change
@@ -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: {<keyPrefix>: 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."
}
]
}
Loading
Loading