Skip to content
Merged
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 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 @@ -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 <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 @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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*<?\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
48 changes: 24 additions & 24 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1324,10 +1324,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 @@ -1336,7 +1338,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 @@ -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 <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 @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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*<?\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 Expand Up @@ -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",
Expand All @@ -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?"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion policy.default.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
44 changes: 34 additions & 10 deletions test/decide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(" "),
Expand All @@ -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(" "),
Expand All @@ -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
];
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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");
});

Expand All @@ -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
Expand Down
Loading
Loading