From 9f9bf82d85c28b8caecdaaca425372b6ce2ef974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Thu, 20 Aug 2026 07:39:32 +0200 Subject: [PATCH] Give the antigravity leg a workspace, and a way past the permission wall Antigravity ignores the process cwd. Print mode opens no workspace at all and works out of ~/.gemini/antigravity-cli/scratch instead, so the leg reviewed an empty dir - one run went looking for the repo with `find $HOME -maxdepth 3`. It takes the workspace as `--add-dir`, which `command` appends since it is the one that knows the repo. OsString and not String: a repo path need not be UTF-8, and to_string_lossy() hands antigravity a U+FFFD path that opens nothing. Headless print mode soft-denies every tool that needs approval (since 1.1.3), which under the stock `toolPermission: request-review` is every shell command. Bare `-p` then exits 1 with "permission check failed", and under `--sandbox` it exits 0 having printed nothing at all - so since #50 made the prompt actually arrive, the leg has been trying to work and dying. `--dangerously-skip-permissions` clears the deny and `--mode plan` is what holds the tree: it refused both a straight "overwrite this file, do not plan" and the same thing routed through the terminal. Not `--disable-slash-commands`, though it fixes prompts that open with `/`: plan mode is built on that same expansion and the flag silently voids it, leaving skip-permissions on with nothing holding the tree. Plan mode is also told by its own system prompt to put the findings in an artifact file and not repeat them in the reply, so stdout came back as "I have created a plan, please review it" with the review itself in a brain dir nothing reads. decorate_prompt appends a coda for that leg only, asking for the answer in the reply and for the workspace to be left alone. Read-only there is a persona and not a sandbox, unlike every other leg, so stop claiming otherwise in agents.rs, README and SKILL.md, and check it instead: `git status --porcelain` before and after the fan-out, with a run note naming antigravity if the tree moved. Note an Ok leg that returned under 300 characters too - a placeholder answer used to render as an ordinary section, and the caller read a silent leg as one with nothing to add. --- README.md | 2 +- skills/postmortemthis/SKILL.md | 5 ++ src/agents.rs | 90 +++++++++++++++++++++++++++++----- src/main.rs | 52 +++++++++++++++++++- src/runner.rs | 2 + 5 files changed, 136 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e490ef9..3c2b2b8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Why really?** The agent that wrote your code is the worst judge of it. -**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call. +**What?** Every coding-agent CLI (Claude Code, Codex, Antigravity, Qwen, Vibe, Grok) reads your diff so you don't have to (you weren't going to). Read-only, in parallel; you stay in your agent, it makes the call. (Read-only is enforced by each agent's own CLI - except Antigravity, which has no such switch and is held to it by its plan mode instead. If the tree moves during a run, you get told.) **How?** One tiny script that installs, updates, and runs all the agents for you, on Windows, macOS, and Linux. No setup, no server, no MCP. diff --git a/skills/postmortemthis/SKILL.md b/skills/postmortemthis/SKILL.md index 9c6bb16..4aa7bc6 100644 --- a/skills/postmortemthis/SKILL.md +++ b/skills/postmortemthis/SKILL.md @@ -12,6 +12,11 @@ skill builds the prompt, runs the fan-out, and synthesizes the answers. It is **read-only by default**: it reports what the agents say, it does not edit, stage, commit, or push. Applying fixes is a separate, explicit step. +Each agent is held read-only by its own CLI - a sandbox, a tool allowlist, a deny-on-approval +mode - with one exception: Antigravity has no such switch, so it runs in its plan mode, which +refuses to write but is a persona rather than a sandbox. The wrapper compares `git status` +before and after the run and says so in the run notes if the tree moved. + ## 1. Decide what to ask The prompt is whatever the user wants several agents to weigh in on. There is **no git diff --git a/src/agents.rs b/src/agents.rs index 0af9d45..9b95b2c 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -1,12 +1,23 @@ use crate::gg; use crate::openrouter; use crate::vibe; +use std::borrow::Cow; +use std::ffi::OsString; use std::path::Path; use std::process::Command; use std::sync::OnceLock; -/// A supported agent CLI. Each runs headless, read-only, in the repo's cwd, -/// using its own native harness and whatever auth the user already has. +/// Appended to Antigravity's prompt only; see `Agent::decorate_prompt`. The +/// "do not modify" half earns its place: read-only on that leg is plan mode's +/// persona, so skipping the plan file without it reads as "stop planning". +const ANTIGRAVITY_CODA: &str = "\n\n---\nPut your findings in this reply: only stdout is \ +captured, so a plan or artifact file is not read back. Do not modify anything in the \ +workspace - this is a review, not a change."; + +/// A supported agent CLI. Each runs headless in the repo's cwd, using its own +/// native harness and whatever auth the user already has. All read-only, but only +/// five by their own CLI - Antigravity has no such switch and rides on its plan +/// mode instead (see `args`), so main.rs watches the tree for it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { Claude, @@ -168,16 +179,46 @@ impl Agent { // and the runner appends the prompt (see reads_stdin). Ok as an argv // operand: gg ships antigravity as a native binary, not a .cmd shim, // so multiline survives. - // Read-only rests on the print-mode default: WITHOUT - // --dangerously-skip-permissions, any write tool is diverted into - // Antigravity's own scratch dir and never touches the workspace - // (verified empirically), while file reads and read-only git run - // against the real cwd. The explicit `--sandbox` flag is - // deliberately NOT used: it hangs headless `-p` runs until the - // print-timeout fires. --print-timeout is parked far above any - // realistic outer --timeout so postmortemthis's own timeout governs, - // not Antigravity's 5m print-mode default. - Agent::Antigravity => vec!["--print-timeout", "24h", "-p"], + // + // --add-dir (appended by `command`, which knows the repo): + // Antigravity ignores the process cwd. Print mode opens no workspace + // at all and works out of ~/.gemini/antigravity-cli/scratch instead - + // one run went looking for the repo with `find $HOME -maxdepth 3`. + // + // --mode plan is what holds this leg read-only. The print-mode + // default does not - it overwrites workspace files happily. Plan mode + // refused both a straight "overwrite this, do not plan" and the same + // thing through the terminal. It is a persona doing the refusing + // though, not a sandbox; a hard guarantee means a throwaway worktree. + // `--sandbox` is not it either - that only restricts the terminal, + // the write tools go right through. + // + // NOT --disable-slash-commands, however tempting: a prompt starting + // with `/` gets eaten as a slash command, but plan mode is built on + // that same expansion and the flag silently voids it ("warning: + // --mode plan has no effect while slash command expansion is + // disabled"), leaving skip-permissions on with nothing holding the + // tree. A leading `/` costs one leg, a voided plan mode costs the + // read-only property. + // + // --dangerously-skip-permissions: headless soft-denies every tool + // that needs approval (since 1.1.3), which under the stock + // `toolPermission: request-review` is every shell command. Without it + // the leg dies either way - bare `-p` exits 1 on "permission check + // failed", under --sandbox it exits 0 having printed nothing. It + // drops the confirmation, not the read-only; that is plan mode's job. + // + // --print-timeout is parked far above any realistic outer --timeout + // so postmortemthis's own timeout governs, not Antigravity's 5m + // print-mode default. + Agent::Antigravity => vec![ + "--print-timeout", + "24h", + "--mode", + "plan", + "--dangerously-skip-permissions", + "-p", + ], // Qwen Code is a Gemini-CLI fork: same read-only approval model. // --auth-type openai pins it to the OpenAI-compatible endpoint // (the OPENAI_* env points that at OpenRouter); the prompt is read @@ -200,6 +241,30 @@ impl Agent { } } + /// The prompt as this agent's CLI should receive it. Antigravity's plan mode + /// (see `args`) is told by its own system prompt to put the findings in an + /// artifact file and not repeat them in the reply, so the review landed in + /// its brain dir and stdout got "I have created a plan, please review it". + /// The coda pulls it back into the reply. Everyone else gets it untouched. + pub fn decorate_prompt<'a>(&self, prompt: &'a str) -> Cow<'a, str> { + match self { + Agent::Antigravity => Cow::Owned(format!("{prompt}{ANTIGRAVITY_CODA}")), + _ => Cow::Borrowed(prompt), + } + } + + /// Args that hand this agent the repo as its workspace, for a CLI that does + /// not take one from the process cwd. Only Antigravity needs it (see `args`); + /// the rest work off current_dir. + fn workspace_args(&self, repo: &Path) -> Vec { + match self { + // OsString, not String: a repo path need not be UTF-8, and + // to_string_lossy() hands Antigravity a U+FFFD path that opens nothing + Agent::Antigravity => vec!["--add-dir".into(), repo.as_os_str().to_os_string()], + _ => vec![], + } + } + /// The base command that reaches this agent's CLI - through gg when a gg is /// available, else the native binary on PATH. Callers layer their own args /// on top (`command` for a review, `login_command` for a login). @@ -225,6 +290,7 @@ impl Agent { codex_exec_command(repo, if openrouter { self.openrouter_model() } else { None }) } else { let mut cmd = runner.base_command(); + cmd.args(self.workspace_args(repo)); cmd.args(self.args()); cmd.current_dir(repo); cmd diff --git a/src/main.rs b/src/main.rs index 32069ba..ab11cac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use clap::{Parser, Subcommand}; use runner::{Outcome, Report}; use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; /// Version reported by `--version`: the git tag baked in at release build @@ -156,8 +157,17 @@ fn run(args: RunArgs) -> Result<()> { selected.iter().map(|a| a.name()).collect::>().join(", ") ); + // Antigravity's leg is held read-only by a persona, not a sandbox (see + // agents::Agent::args), so "it did not write" is an assumption about a CLI + // that moves fast. Better to notice than to trust a comment. + let tree_before = tree_state(&cwd); + let reports = execute(&selected, &skip_native, &prompt, &cwd, timeout)?; + let tree_touched = selected.contains(&Agent::Antigravity) + && tree_before.is_some() + && tree_state(&cwd) != tree_before; + // When --out is set, write each agent's output to a file and print the // paths first, so they survive even if the stdout body is later truncated // by the caller. stdout still carries the full sections by default. @@ -175,7 +185,7 @@ fn run(args: RunArgs) -> Result<()> { print!("\n\n{}", report_section(r)); } - let notes = run_notes(&reports, &selected, &settings); + let notes = run_notes(&reports, &selected, &settings, tree_touched); if !notes.is_empty() { print!( "\n\n---\n\n# postmortemthis run notes (operational; not part of the review)\n\n{}\n", @@ -265,12 +275,28 @@ fn strip_ansi(s: &str) -> String { out } +/// Under this many characters, an agent that exited cleanly probably answered +/// with a placeholder, not a review. A terse-but-real reply costs one note; a +/// lost leg costs the caller an opinion it thinks it had. +const THIN_OUTPUT: usize = 300; + /// Operational notes for the calling agent: what it can fix or change on a /// later run. Empty when there is nothing worth saying. Kept terse and /// imperative because the consumer is an LLM composing the next command. -fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settings) -> Vec { +fn run_notes( + reports: &[Report], + selected: &[Agent], + settings: &settings::Settings, + tree_touched: bool, +) -> Vec { let mut notes = Vec::new(); + if tree_touched { + notes.push( + "- the working tree changed while the agents ran. Every leg but antigravity is held read-only by its own CLI; antigravity's plan mode is a persona, not a sandbox, so it is the one to suspect. Run `git status` and `git diff` before trusting the review above.".to_string(), + ); + } + for r in reports { let name = r.agent.name(); if r.fell_back { @@ -285,6 +311,16 @@ fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settin "- {name}: timed out. Raise --timeout or shorten the prompt." )); } + // Exit status is a poor test of "answered": an agent can exit 0 having + // printed "I have created a plan, please review it", or nothing at all + // (claude via OpenRouter, see openrouter_env). Both render as a normal + // section, so the caller reads a silent leg as one with nothing to add. + let len = r.output.trim().chars().count(); + if r.outcome == Outcome::Ok && len < THIN_OUTPUT { + notes.push(format!( + "- {name}: exited cleanly but returned only {len} characters. That is usually a placeholder ('I have created a plan...'), not an answer - read its section before counting it as an opinion, and re-run that agent alone if you need it." + )); + } } // Agents that exist but were not run for lack of usable credentials. An @@ -416,6 +452,18 @@ fn plan_run( /// Bring up per-run scratch state (Vibe's VIBE_HOME), prewarm the gg tools, and /// fan out. Shared by `run` and `setup`'s test so both take the same path; the /// Vibe home guard is held until run_all returns. +/// `git status --porcelain` for the review cwd: a cheap tree fingerprint, +/// compared before and after a run. None outside a git repo - postmortemthis +/// runs in plain folders too. Never printed. +fn tree_state(cwd: &Path) -> Option { + let out = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(cwd) + .output() + .ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + fn execute( selected: &[Agent], skip_native: &[Agent], diff --git a/src/runner.rs b/src/runner.rs index 1fda11a..07fd33f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -151,6 +151,8 @@ fn run_attempt( ) -> Report { let started = Instant::now(); let mut cmd = agent.command(repo, openrouter); + let prompt = agent.decorate_prompt(prompt); + let prompt = prompt.as_ref(); // Most read the prompt on stdin; vibe (always) and grok's native leg take it // as a trailing argument and get no stdin. let on_stdin = agent.reads_stdin(openrouter);