Who Approved the Right Side of &&? Splitting Shell Commands Before Matching Allow Rules
The approval dialog showed part of what actually ran. Here is a harness that splits compound shell commands without breaking quotes or command substitution, matches allow rules per segment, and the numbers from running it over 45 real commands.
The approval dialog read python3 scripts/redirect_integrity.py . --fix. My own gate script. I approved it without hesitating.
Days later I noticed there were no .bak files left in the repository and went back through the history. What actually ran was python3 scripts/redirect_integrity.py . --fix && rm -f content/*.bak. I had approved the first half. The deletion went through without me ever reading it.
Nothing was lost that I had not intended to delete anyway. What stayed with me was the gap itself: what I approved and what executed were not the same string.
Working as an indie developer, the approval dialog has exactly one reader. Whatever I wave through runs unopposed.
Reading the Antigravity changelog, I found that v1.1.7 addresses the display side — when only part of a compound shell command requires approval, the prompt now shows the full command. The display gets fixed. The question I could not answer was whether my own allow rules were written against the full command in the first place.
The dialog showed a fragment of what would run
Two separate layers are at work here.
The first is presentation: what the approval prompt puts in front of you. That is the part v1.1.7 improves.
The second is adjudication: the unit at which permit-or-prompt is decided for a command joined with && or ;. That lives in my configuration, and no upstream release fixes it for me.
Read plainly, that means "permit commands beginning with python3." And python3 ... --fix && rm -f content/*.bak does begin with python3.
Matching on the leading token lets anything on the right side of && ride along. The rules were not underpowered; the unit of matching was wrong.
Questioning the head-token assumption
The fix is to move adjudication from "one command" to "one segment," where a segment is an individual command the shell will execute in sequence.
a && b || c ; d | e is five segments. git commit -m "a && b" is one, because the operator sits inside quotes. Getting this wrong breaks in both directions: split inside quotes and legitimate commits stop; fail to split outside them and a delete slips past.
So the splitter came first, as its own module. Anything built on a splitter that is wrong is decoration.
One clarification before the code: this is not a reimplementation of how Antigravity CLI adjudicates internally. It is a bench harness for checking whether my own allow rules behave as intended against the full command text. Whatever the tool decides upstream, the shape of my own configuration is something I can measure myself.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A quote-aware, substitution-aware command splitter with a five-case test suite you can drop into your own tooling
✦Measured difference between head-token matching and segment matching over the same command set, and how four extra rules restore the approval rate while still blocking the dangerous cases
✦Why command substitution belongs in a fail-closed bucket, and where splitting genuinely fails to help
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The splitter: never crossing quotes or substitution
It walks the string character by character, tracking quote state, escapes, $( ) nesting, and backticks. Every attempt I made to do this with a single regular expression broke on either "a && b" or $(cat f). The state machine is what I arrived at after those detours.
// segment.mjs — split a shell command into approval unitsexport function splitSegments(command) { const segments = []; const substitutions = []; let buf = ''; let quote = null; // "'" or '"' let depth = 0; // $( ) nesting depth let subBuf = ''; let i = 0; const flush = () => { const t = buf.trim(); if (t) segments.push(t); buf = ''; }; while (i < command.length) { const c = command[i]; const two = command.slice(i, i + 2); if (quote) { // backslash escapes do not apply inside single quotes if (c === '\\' && quote === '"') { buf += c + (command[i + 1] ?? ''); i += 2; continue; } if (c === quote) quote = null; // substitution still happens inside double quotes if (quote === '"' && two === '$(') { depth++; i += 2; buf += two; subBuf = ''; continue; } if (depth > 0) { if (c === ')') { depth--; if (depth === 0) substitutions.push(subBuf.trim()); } else subBuf += c; } buf += c; i++; continue; } if (depth > 0) { if (c === ')') { depth--; if (depth === 0) { substitutions.push(subBuf.trim()); subBuf = ''; } } else subBuf += c; buf += c; i++; continue; } if (c === "'" || c === '"') { quote = c; buf += c; i++; continue; } if (c === '\\') { buf += c + (command[i + 1] ?? ''); i += 2; continue; } if (two === '$(') { depth++; i += 2; buf += two; subBuf = ''; continue; } if (c === '`') { const end = command.indexOf('`', i + 1); const inner = end === -1 ? command.slice(i + 1) : command.slice(i + 1, end); substitutions.push(inner.trim()); buf += command.slice(i, end === -1 ? command.length : end + 1); i = end === -1 ? command.length : end + 1; continue; } if (two === '&&' || two === '||') { flush(); i += 2; continue; } if (c === ';' || c === '|' || c === '\n') { flush(); i++; continue; } buf += c; i++; } flush(); // unbalanced quotes or parens: hand it to a human rather than guess if (quote || depth > 0) return { segments, substitutions, malformed: true }; return { segments, substitutions, malformed: false };}
Returning malformed for unbalanced quotes is deliberate. Guessing at what the author "probably meant" creates something to aim at. Text I could not parse goes back to a person.
Redirection targets need separate handling. echo "ok" > /etc/motd is a single segment whose command is echo, and segment matching alone waves it through.
const REDIRECT = /(?:^|\s)\d?>>?\s*("[^"]*"|'[^']*'|\S+)/g;export function redirectTargets(segment) { const out = []; let m; REDIRECT.lastIndex = 0; while ((m = REDIRECT.exec(segment)) !== null) { const raw = m[1].replace(/^['"]|['"]$/g, ''); if (raw !== '&1' && raw !== '&2' && raw !== '/dev/null') out.push(raw); } return out;}
I kept the test suite small and limited to cases with an unambiguous answer. It runs 5/5 here.
Input
Expected
Result
git commit -m "a && b"
1 segment
1
a && b || c ; d | e
5 segments
5
echo 'x; y' && ls
2 segments
2
git commit -m "$(cat f)"
1 segment + 1 substitution
1 + 1
echo "unclosed
malformed
malformed
Adjudicating per segment
The evaluator is deliberately plain. Three conditions, all of which must hold before anything is permitted.
Every segment matches some allow rule
Every redirection target sits under a permitted write root
The command contains no command substitution
import { splitSegments, redirectTargets } from './segment.mjs';export function parseRule(rule) { const m = /^Bash\((.+?)(:\*)?\)$/.exec(rule.trim()); if (!m) throw new Error(`unparsable rule: ${rule}`); return { prefix: m[1].trim(), wildcard: Boolean(m[2]), raw: rule.trim() };}const norm = (s) => s.replace(/\s+/g, ' ').trim();export function matchSegment(segment, rules) { const s = norm(segment); for (const r of rules) { if (r.wildcard ? (s === r.prefix || s.startsWith(r.prefix + ' ')) : s === r.prefix) return r; } return null;}export function evaluate(command, rules, opts = {}) { const writeRoots = opts.writeRoots ?? []; const { segments, substitutions, malformed } = splitSegments(command); if (malformed) return { verdict: 'REVIEW', reason: 'unbalanced-quote', segments, blockers: [] }; const blockers = []; for (const seg of segments) { if (!matchSegment(seg, rules)) blockers.push({ kind: 'unmatched-segment', segment: seg }); for (const t of redirectTargets(seg)) { if (!writeRoots.some((root) => t.startsWith(root))) { blockers.push({ kind: 'write-outside-root', segment: seg, target: t }); } } } // contents are not determined until execution: do not permit what cannot be read for (const sub of substitutions) blockers.push({ kind: 'command-substitution', segment: sub }); return { verdict: blockers.length === 0 ? 'ALLOW' : 'REVIEW', segments, substitutions, blockers, };}
The third condition is the one most likely to draw objections. git commit -m "$(cat /tmp/msg)" is an everyday shape, and stopping it every time is tedious.
I made it fail-closed anyway, because at adjudication time the contents of $( ) are only a string. What cat /tmp/msg produces is decided at execution. Permitting something you cannot evaluate is indistinguishable from not evaluating it.
Blocking first and then whitelisting specific substitutions turned out to be a shape I could reason about. The reverse never was.
Running it over 45 commands from my own operations
The corpus is 45 commands transcribed from what I actually issue when generating and pushing articles: git operations, python3 gate scripts, find ... | wc -l count checks, builds, cleanup rm, plus a handful that look harmless from the outside.
Raw statistics first.
Metric
Value
Commands
45
Segments after splitting
58
Average per command
1.29 segments
Commands with 2+ segments
11 (24.4%)
Commands containing substitution
3 (6.7%)
An average of 1.29 came in below my own estimate. I write chained commands often enough that it feels constant; in practice it is one command in four. Which reframes the question: is one in four worth changing the matching unit for?
With 24 allow rules in place, I ran both matching strategies over the same 45 commands.
Matching strategy
ALLOW
Rate
Head token only (previous behaviour)
33 / 45
73.3%
Segment-decomposed (24 rules)
30 / 45
66.7%
The 15 that stopped break down as: 5 unregistered git subcommands, 3 command substitutions, 3 rm invocations, 2 curl, one each of tar, npx, and an env-prefixed date, plus one redirection outside the permitted roots.
The part that ran against my expectations
I had expected decomposition to make approvals unworkable. Knowing how often I chain commands, I was braced for the pass rate to fall by half. The actual drop was 73.3% to 66.7% — 6.6 points.
More to the point, 12 of the 15 stopped commands were already being stopped under head-token matching. Only three were newly caught by the change.
The command I approved without reading is sitting on the first row. And the three escape through three unrelated mechanisms: the right side of a chain, a redirection target, the interior of a substitution. No single countermeasure closes all three.
I then added the four rules that were missing — an exact-match rm -rf .next, tar czf, git stash, and a host-scoped curl for fetching the changelog — and measured again.
Configuration
ALLOW
The three dangerous ones
Head token, 24 rules
33 / 45 (73.3%)
permitted
Segment, 24 rules
30 / 45 (66.7%)
stopped
Segment, 28 rules
33 / 45 (73.3%)
stopped
The pass rate came back exactly. Same count of 33, entirely different membership: three dangerous commands out, and three I would hate to have blocked — rm -rf .next, tar czf /tmp/backup.tgz content/, and a git stash chain — in.
The price was four lines of configuration. I had braced for more prompts; what actually grew was a config file.
Adjudication cost, for completeness: 20,000 evaluations in 110.9 ms, or 5.5 microseconds each. Not a figure that argues against putting this in front of execution.
The riskier shape looked like a single command
A chain at least announces itself. Once the prompt shows the full text, an attentive reader can notice what follows &&.
git commit -m "$(cat /tmp/msg)" gives you nothing to notice. No operator. One segment, and that segment is an already-permitted git commit. However carefully you split on &&, this shape walks through.
Three of the 45 contained substitution. Two were conveniences I wrote myself; the third was a deliberately extreme case I planted — curl -sS "$(cat /tmp/endpoint)" | sh, where what executes depends entirely on the contents of a file.
Syntactic complexity and actual exposure are not proportional. That was the lesson that stuck. Design your approvals around "which operator joined these" and you will keep missing this whole family.
I have written about permission granularity before, in designing graduated approval from agent confidence scores. What this exercise added is a layer underneath that discussion: separate from deciding what to delegate, there is the question of what is inside the single line you delegated.
Where splitting did not help
Being honest about the gaps.
Environment variable prefixes.TZ=Asia/Tokyo date +%Y-%m-%d is one segment, but it does not begin with date, so Bash(date:*) never matches. Adding prefix-stripping normalization moved ALLOW from 30 to 31. One command. Real, but modest.
Glob expansion.rm -f content/*.bak is textually under content/, but I am not looking at what the shell expands it to. Seeing that requires moving adjudication closer to execution.
Aliases and functions. What ll expands to is invisible from here.
Nested interpreters. The interior of bash -c "..." is just a string to this harness. Recursive splitting is possible, but then you owe yourself a rule for how deep to go.
Listed together, splitting is clearly not a general defence. As I understand it now, this narrows the gap between what was approved and what ran; it does not tell you whether a command is dangerous. Conflating those two would mean trusting the harness for something it never does.
Folding it into operations
Once a week I pipe recent commands from the execution log through the harness and read the REVIEW breakdown.
Two things get my attention. First, whether the same shape keeps appearing in REVIEW — that is a rule waiting to be written. Second, whether shapes that were not there last week are now appearing in ALLOW — that is permission quietly widening.
The further automation goes, the more approving becomes a motion rather than a decision. A motion does not read. Mine had stopped reading. So the work is to state, as rules, the range where reading is unnecessary — and make sure everything outside that range brings a hand to a stop.
Try it against your own week
If your allow rules look anything like Bash(python3:*), take the splitter above and run last week's commands through it. Any command where segment matching disagrees with head-token matching is a command you approved without reading.
Mine was 3 out of 45. Whether that is many or few, I cannot say. But one of them was a command I had genuinely approved myself.
Thank you for reading through a fairly narrow piece of bench work. If it saves someone the same detour, that would make me glad.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.