Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "antigravity",
"version": "0.22.4",
"version": "0.22.5",
"description": "Run the Antigravity CLI (Gemini) as a collaborating AI inside Claude Code, with intelligent model routing across the SDLC. Claude conducts — requirements, architecture, the hard 20%, verification, review — and routes deterministic, high-volume work (scaffolding, tests, first-pass review, migrations, web/Vertex AI Search) to Antigravity. Hybrid agentic engineering; lower token cost as a financial lever.",
"author": {
"name": "linyuting"
Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,78 @@
All notable changes to **Antigravity for Claude Code**. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); versions are in `.claude-plugin/plugin.json`.

## 0.22.5
- **`doctor` now asks agy which model it will run, instead of inferring it from a version
string.** 0.22.4 added a warning for agy below 1.1.10, where `--model` was ignored in
headless `-p`. That warning is the best a version comparison can do, and a version
comparison is a proxy: it is right about the releases we know about and silent about
every other way the flag can fail to land. agy 1.1.11 answers the read-only slash
commands in print mode without starting an agent turn, so `doctor` requests a tier model
with `-p /model` and reports which one comes back — `usage.total_tokens: 0`, no quota
spent, no conversation left behind. It reads the tab-separated reply's slug and matches
it against a tier configured as a display name, the same either-direction comparison
`agy models` needed in 0.20.x.
**Gated at 1.1.11 and tested as a fact, not as prose.** Below that version the slash
command is not recognised, falls through as literal prompt text, and the model answers as
though it had run — so probing there would spend a real turn *and* then trust the answer
it invented. The stub agy in the suite logs every invocation, so "never probes below
1.1.11" is asserted against the log. An empty answer draws no conclusion in either
direction: an older build than the version claims, a hang, or a plan that refuses the
probe is not evidence that routing is broken.
- **`doctor` validates `permissions.allow` entries.** The plugin recommends such a rule in
eight places as the *narrow* alternative to `--yolo`, and the recommendation ships a
placeholder — `write_file(<dir>)`. A rule agy cannot parse announces itself in neither
direction, and which way it fails depends on the version: from **1.1.11** it matches
nothing, so the grant is absent and the write is soft-denied (exit 15) with the rule
sitting visibly in the file; **before 1.1.11** an entry that tokenized to zero command
words matched **every** command and silently auto-approved anything the agent ran —
broader than the `--yolo` it was chosen instead of. `doctor` flags the entry, names the
reason, and scopes the consequence **twice**: to your agy version, and to the reason.
Every class means the grant is absent; only a `command(...)` rule naming no command also
carries the match-everything history, so that sentence is printed only when such an entry
is actually present. A mistyped `write_file()` never had it, and putting a security claim
in front of someone holding one would be worse than saying less. (Both reviewers caught
the first cut attaching it to every finding.)
The zero-command-word test follows upstream's own examples (`command(time)` — a shell
reserved word that prefixes a command without being one — a comment-only entry, and an
empty compound `()`), plus the unsubstituted `<...>` placeholder, which is ours — matched
as that *shape*, not as a bare angle bracket, so a rule carrying a literal redirect
(`command(echo hi > /tmp/f)`) is not misread as a template nobody filled in. Rules it
cannot judge are left alone: unbalanced quotes are agy's parser's business, and
`write_file(...)` is a different matcher from `command(...)`. A false positive here sends
someone to edit a rule that was always fine, so the well-formed case is pinned as hard as
the broken ones.
- **Verified against agy 1.1.11, no change needed:** all three tiers still resolve
(`flash` → `gemini-3.5-flash-high`, `flash-lo` → `-low`, `pro` → `gemini-3.1-pro-high`);
the exit-14 classifier still fires on an unknown model; and 1.1.11's reworded
model-loading errors do not disturb the exit-15 path, whose message still contains
`auto-denied` and `permissions.allow` verbatim — two independent anchors the classifier
already matches.
**`/usage` and `/quota` were considered for `doctor` and rejected on evidence:** they
return zero bytes here, which is not a bug but this account type — 1.1.11's own notes say
credits do not apply to accounts signed in through a Google Cloud project or ADC.
`/model`, `/effort` and `/skills` all return data on the same setup, which is what made
the probe above possible.

- **The suite had a false-negative construct in 25 assertions, and this release woke it
up.** `printf '%s' "$x" | grep -q PAT` is the shape fixed in 0.21.1 and explained in a
comment in this very file: `grep -q` exits at the first match and closes the pipe, the
writer dies of SIGPIPE (141), and `set -o pipefail` marks the whole pipeline failed — so
the assertion reads "not found" while the text is right there. The window is whatever the
writer still has to emit *after* the matched line, which is why it survives review and
why adding output below a match can revive it: the 1.1.9 version-gate assertion started
failing 1–4 times in 8 concurrent runs on this branch and never on master.
Diagnosed rather than guessed. Instrumenting the *test* made it vanish (it changes the
timing), so the trace went inside `doctor` instead: `mktemp` never failed and `AGY_VER`
was never empty, which left only the assertion misreading output that had in fact been
printed. All 25 now use a `case`-based `has()` — same test, no second process, no pipe —
and the five `sed … | grep -q` source scans give `grep` a process substitution instead,
so `sed`'s death is no longer `pipefail`'s business. Those five failed **silently**: a
false negative there reads as "the property holds", and among them are the guards for
the delegate wrapper's `--help` probe, `agy_guard`'s pipe rule, and the `sort -V`
dependency. 24 consecutive clean runs at the concurrency that reproduced it; each
converted scan re-checked by mutation.

## 0.22.4
- **`--tier` did nothing on agy below 1.1.10, and nothing said so.** agy 1.1.10 fixed
`--model` and `--effort` being *ignored in headless `-p`* — the flag was applied after
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ Delegation doesn't save money by itself — these do (also in the skill):
- **`permissions.allow` in `~/.gemini/antigravity-cli/settings.json`** — a
`write_file(<dir>)` entry allows writes **recursively beneath `<dir>`** and needs no
flag. This is the narrower grant and usually the right one.
**`<dir>` is a placeholder — substitute a real path.** A rule agy cannot parse says
nothing in either direction: from **agy 1.1.11** it matches nothing, so the grant you
think you have is absent and the write is soft-denied for no visible reason; **before
1.1.11** an entry that tokenized to zero command words (upstream's examples:
`command(time)`, a comment-only entry, `()`) matched **every** command and silently
auto-approved anything the agent ran — broader than the `--yolo` it was chosen instead
of. `agy-doctor` checks your entries and names which failure applies to your version.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph is about the write_file(<dir>) placeholder specifically, but the "before 1.1.11 ... matched every command" consequence it describes is scoped to command(...)-shaped rules with zero words — per scripts/doctor.sh's own bad_allow_rules() (and the PR's second commit message: "A mistyped write_file(<dir>) never had it. Telling someone holding one that their config auto-approves everything is a security claim about a situation they are not in.").

bad_allow_rules() classifies an unsubstituted <dir> placeholder as "unparseable", not "zerowords" — only "zerowords" (the command(time)/comment-only/() examples) carries the match-everything history, and doctor.sh prints that sentence only when a zerowords entry is present. As written, this bullet tells a reader whose write_file(<dir>) was never substituted that pre-1.1.11 it "matched every command and silently auto-approved anything the agent ran" — which, per the code's own distinction, isn't true for that matcher.

Same overclaim is copy-pasted into docs/TROUBLESHOOTING.md:113-118, docs/POC-PLAYBOOK.md:106-111, skills/antigravity/SKILL.md:220-225, and (shorter form) agents/antigravity-delegate.md:89-92. commands/delegate.md avoids it and reads correctly.

- **`--yolo`** (`--dangerously-skip-permissions`) — auto-approves **all** tools, not just
writes. Needed when no rule covers the target, and for web / Vertex AI Search / terminal
tools.
Expand Down
7 changes: 7 additions & 0 deletions docs/POC-PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ After each lever: rerun the task → rerun the gate → keep only if quality hel
- **`permissions.allow` in `~/.gemini/antigravity-cli/settings.json`** — a
`write_file(<dir>)` entry allows writes **recursively beneath `<dir>`** and needs no
flag. This is the narrower grant and usually the right one.
**`<dir>` is a placeholder — substitute a real path**, and run `agy-doctor` before a
measured run. A rule agy cannot parse is silent in both directions: from **agy
1.1.11** it matches nothing, so the grant is absent and the write is soft-denied with
the rule sitting visibly in the file; **before 1.1.11** an entry naming no command
(`command(time)`, a comment-only entry, `()`) matched **every** command and silently
auto-approved anything the agent ran. Either failure makes an arm's write behaviour a
second uncontrolled variable — exactly what §5 says a cost claim cannot carry.
- **`--yolo`** (`--dangerously-skip-permissions`) — auto-approves **all** tools, not just
writes. Needed when no rule covers the target, and for web / Vertex AI Search / terminal
tools.
Expand Down
8 changes: 8 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ run still "succeeds"** ([#10](https://github.com/yuting0624/antigravity-for-clau
This is the rule agy's own soft-deny message is naming. Confirmed on agy 1.1.9 by a
controlled A/B ([#37](https://github.com/yuting0624/antigravity-for-claude-code/issues/37));
a glob form (`write_file(/path/**)`) was reported *not* to match.
**Substitute a real path for `<dir>`** — and if the rule is in place and the write is
*still* soft-denied, suspect the rule before suspecting agy. An entry agy cannot parse
is silent both ways: from **1.1.11** it matches nothing (so you get exactly this
exit 15, with the rule sitting right there in the file), and **before 1.1.11** an entry
that tokenized to zero command words — `command(time)`, a comment-only entry, `()` —
matched **every** command and silently auto-approved anything the agent ran. Run
`agy-doctor`: it validates each entry and tells you which of the two applies to your
version.
- **Or pass `--yolo`** (`--dangerously-skip-permissions`) — works across all agy versions,
but auto-approves **all** tools, not just the write. Required anyway for web / Vertex AI
Search / terminal when no rule covers them. (`--mode accept-edits` only wrote headless on
Expand Down
150 changes: 150 additions & 0 deletions scripts/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,88 @@ sys.exit(0 if n else 1)
' "${files[@]}" 2>/dev/null
}

# Report `permissions.allow` entries agy cannot use as written. Prints one
# TAB-separated `<entry>\t<reason>\t<class>` line each and returns 0 when any were found.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this docstring says the emitted line shape is <entry>\t<reason>\t<class>, but the actual print (line ~194) emits "%s\t%s\t%s" % (cls, why, esc(t)) — class first, then reason, then entry. The reader (while IFS=$'\t' read -r cls why rule, line ~356) correctly matches the real order. Worth updating the comment to <class>\t<reason>\t<entry> so it doesn't mislead the next person who touches this format (which the PR itself flags as security-sensitive: a shifted field silently drops the security consequence).

#
# The class matters because the CONSEQUENCE is not shared. `zerowords` is the specific
# thing agy 1.1.11 fixed — a `command(...)` rule naming no command, which used to match
# every command — and only that class carries the auto-approve-everything history.
# `unparseable` is everything else: agy cannot use the entry, so the grant is not in
# effect, and nothing beyond that is known. Reporting the first consequence for the
# second would put a security claim in front of someone it does not apply to.
#
# WHY doctor owns this. The plugin recommends a `permissions.allow` rule in eight
# places as the NARROW alternative to `--yolo`, and that recommendation carries a
# placeholder — `write_file(<dir>)`. A rule that agy cannot parse does not announce
# itself in either direction: before 1.1.11 an entry that tokenizes to zero command
# words matched EVERY command and silently auto-approved anything the agent ran, which
# is broader than the `--yolo` it was chosen over; from 1.1.11 it matches nothing, so
# the grant simply is not there and the write is soft-denied for no visible reason.
# Same typo, opposite failures, no message either time.
bad_allow_rules() {
command -v python3 >/dev/null 2>&1 || return 1
python3 -c '
import json, re, shlex, sys

try:
perm = (json.load(open(sys.argv[1])) or {}).get("permissions") or {}
allow = perm.get("allow")
except Exception:
sys.exit(1)
if not isinstance(allow, list):
sys.exit(1)

# Shell reserved words that may PREFIX a command without being one, so a rule made
# only of them names no command. agy 1.1.11 describes the class it fixed as an entry
# that "tokenizes to zero command words" and gives command(time) as its own example.
PREFIX = {"time", "!", "{", "}", "[[", "]]", "if", "then", "elif", "else", "fi",
"case", "esac", "for", "select", "while", "until", "do", "done", "in",
"function", "coproc"}

# A placeholder is the <...> shape AND placeholder-shaped content: one unbroken token,
# no whitespace. A bare angle bracket is a literal redirect — command(echo hi > /tmp/f) —
# and so is a PAIR of them, command(sort < in > out), where everything between the two
# brackets is a filename rather than a template. Requiring an unbroken token separates
# <dir> and <path/to/repo> from both.
PLACEHOLDER = re.compile(r"<[A-Za-z0-9_./\-]+>")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor false-positive risk: PLACEHOLDER = re.compile(r"<[A-Za-z0-9_./\-]+>") is checked against the entire rule string, not just inside a command(...)/write_file(...) argument. It correctly avoids the two tested redirect shapes (echo hi > /tmp/f, sort < in > out) because those have whitespace or two unmatched brackets, but a legitimate single-token angle-bracket literal with no whitespace — e.g. command(grep -F '<TAG>' file.txt) — would still match the placeholder shape and get misclassified as "unsubstituted placeholder" even though it's real, working content. Not tested either direction. Given the PR's own stated bar ("a false positive here sends someone to edit a rule that was always fine"), this seems like a gap worth at least a comment acknowledging the trade-off, if not a test.


bad = []
for e in allow:
if not isinstance(e, str):
bad.append((repr(e), "not a string", "unparseable")); continue
t = e.strip()
if not t:
bad.append(("(empty string)", "empty entry", "unparseable")); continue
if PLACEHOLDER.search(t):
bad.append((t, "unsubstituted placeholder — replace <...> with a real value",
"unparseable")); continue
i = t.find("(")
if i < 0 or not t.endswith(")"):
continue # not the NAME(...) shape; not ours to judge
name, inner = t[:i].strip(), t[i + 1:-1]
if not inner.strip():
# Bare `()` is one of the zero-command-word examples upstream gives, and so is
# command(). An empty body on any OTHER matcher, e.g. write_file(), is merely
# unusable and carries none of that history.
bad.append((t, "empty rule body",
"zerowords" if name in ("", "command") else "unparseable")); continue
if name != "command":
continue # write_file(...) etc. are a different matcher
try:
words = shlex.split(inner, comments=True)
except ValueError:
continue # unbalanced quotes: agy parses these, not us
while words and words[0] in PREFIX:
words.pop(0)
if not words:
bad.append((t, "tokenizes to zero command words", "zerowords"))

for t, why, cls in bad:
print("%s\t%s\t%s" % (t, why, cls))
sys.exit(0 if bad else 1)
' "$1" 2>/dev/null
}

# True when version $1 is strictly older than $2. Pure shell on purpose: `sort -V` is
# not universal, and when it is missing the command substitution comes back EMPTY, the
# comparison quietly fails, and the check it guards never fires — a version gate that
Expand Down Expand Up @@ -193,6 +275,42 @@ if command -v agy >/dev/null 2>&1; then
info "agy is multi-model/plan-dependent — remap tiers via CLAUDE_PLUGIN_OPTION_TIER_* (or set _DEFAULT_MODEL), or pass --model <name from \`agy models\`)"
fi
done

# 2c. Does --model actually TAKE EFFECT? Being listed in `agy models` and being
# honoured are different questions, and for three releases the answer to the second
# one was no (see the 1.1.10 warning above) while everything here still read green.
#
# The check above can only infer that from a version string. 1.1.11 answers the
# read-only slash commands in print mode without starting an agent turn, so doctor
# can stop inferring and ASK: request a tier model, see which one comes back. Costs
# no tokens, no quota, and leaves no conversation behind (`usage.total_tokens: 0`).
#
# Gated at 1.1.11 on purpose. Below it the command is not recognised, falls through
# as literal prompt text, and the model answers as though it had run — so the probe
# would spend a real turn AND return a made-up answer. Verified on 1.1.11: display
# names and slugs both work, and all three tiers come back as themselves.
case "${AGY_VER:-}" in
''|*[!0-9.]*) : ;;
*)
if ! ver_lt "$AGY_VER" 1.1.11; then
# cut -f1: the reply is one tab-separated record, `<slug>\t<display name>`.
EFF="$(agy_guard 20 --model "$FLASH" -p /model 2>/dev/null | head -1 | cut -f1)"
EFF_N="$(norm_model "$EFF")"; WANT_N="$(norm_model "$FLASH")"
if [ -z "$EFF_N" ]; then
# No answer at all: a hang, an older build than the version claims, or a
# plan that refuses the probe. Not evidence of breakage — stay quiet rather
# than report a failure doctor cannot actually substantiate.
:
elif case "$WANT_N" in *"$EFF_N"*) true ;; *) case "$EFF_N" in *"$WANT_N"*) true ;; *) false ;; esac ;; esac; then
ok "--model takes effect (asked for '$FLASH', agy reports '$EFF')"
else
warn "--model does NOT take effect: asked for '$FLASH', agy reports '$EFF'"
info "every delegation runs '$EFF' instead, and nothing in the output says so."
info "--tier / tier_* remaps are therefore inert. Check for a persisted default"
info "or a profile overriding it, then re-run doctor."
fi
fi ;;
esac
elif [ "$AGY_TIMED_OUT" -eq 0 ]; then
# Empty WITHOUT a timeout kill: genuinely no models -> auth/network is the likely cause.
bad "agy could not list models (not authenticated, or no network)"
Expand All @@ -208,6 +326,38 @@ if [ -f "$SETTINGS" ]; then
LOC="$(sed -n 's/.*"location"[: ]*"\([^"]*\)".*/\1/p' "$SETTINGS" | head -1)"
ok "agy settings: ${SETTINGS/#$HOME/~}"
[ -n "$PROJ" ] && info "GCP project: $PROJ location: ${LOC:-?}"

# 3b. permissions.allow entries agy cannot use as written.
if BAD_RULES="$(bad_allow_rules "$SETTINGS")"; then
warn "permissions.allow: $(printf '%s\n' "$BAD_RULES" | grep -c .) entry/entries agy cannot use as written"
ZEROWORDS=0
while IFS="$(printf '\t')" read -r rule why cls; do
Comment thread
quorum-code-review[bot] marked this conversation as resolved.
Outdated
[ -n "$rule" ] || continue
info "$rule — $why"
[ "$cls" = zerowords ] && ZEROWORDS=1
done <<EOF
$BAD_RULES
EOF
# Every class above means the grant is not in effect. Only the zero-command-word
# class ALSO has the pre-1.1.11 history of matching everything, so that sentence is
# printed only when such an entry is actually present — putting a security claim in
# front of someone holding a mistyped write_file() would be worse than saying less.
info "an entry agy cannot use grants nothing, so the tool it was meant to cover is"
info "still soft-denied (exit 15) with nothing in the message naming the rule."
if [ "$ZEROWORDS" -eq 1 ]; then
case "${AGY_VER:-}" in
''|*[!0-9.]*)
info "a rule naming no command is also version-sensitive: before agy 1.1.11 it"
info "matched EVERY command. Check your version." ;;
*)
if ver_lt "$AGY_VER" 1.1.11; then
info "worse on agy $AGY_VER: a rule naming no command matches EVERY command there"
info "and silently auto-approves anything the agent runs — broader than the --yolo"
info "it was chosen instead of. Fix the entry, or \`agy update\` to 1.1.11+."
fi ;;
esac
fi
fi
else
info "no agy settings.json yet (${SETTINGS/#$HOME/~})"
fi
Expand Down
Loading
Loading