From 5373be9263fc204c90569d435faa3eb7690d1bb3 Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi Date: Fri, 23 Oct 2026 17:52:10 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20[feat]=20host:=20detect=20Googl?= =?UTF-8?q?e=20Antigravity=20(agy)=20and=20accept=20it=20as=20config=20hos?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - super-board-host.sh answers agy on ANTIGRAVITY_AGENT=1 (observed on agy 1.4.6), after the Codex ids and OPENCODE, before CLAUDECODE; SUPER_BOARD_HOST and config host take agy - setup check accepts host agy; schema, onboard and run-workflow name it (no lane backend yet: the run halts like opencode) - docs/hosts.md: agy row, detection step and env evidence; tests for the sign, the override, the config key and the empty/id-only cases Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/hosts.md | 16 ++++++++-- scripts/super-board-host.sh | 31 ++++++++++++------- scripts/super-board-setup.py | 4 +-- .../super-board/references/config-schema.json | 5 +-- skills/super-board/references/onboard.md | 2 +- skills/super-board/references/run-workflow.md | 9 +++--- tests/test-host.sh | 14 +++++++++ tests/test-setup.sh | 4 +-- 9 files changed, 61 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7735ef43..bd18ab0f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Or as a Claude Code plugin: > [!NOTE] > The plugin ships the skills only. Run `/super-board:super-board onboard` and its ๐Ÿ” Checks step adds the guard hooks, scripts and workflows. Needs Claude Code, `gh`, `jq`, bash 3.2+ and Python 3.9+; the installer checks. -Running from Codex or OpenCode? See [supported hosts](docs/hosts.md) for tested versions, sign-in and gaps. +Running from Codex, OpenCode or Google Antigravity? See [supported hosts](docs/hosts.md) for tested versions, sign-in and gaps. Optional post-run learning can delegate an already installed `calibrate` skill after verified selected work completes. It applies only to explicitly permitted existing diff --git a/docs/hosts.md b/docs/hosts.md index 57ba5403..4567f13e 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -8,6 +8,7 @@ super-board runs inside an agent host: the CLI whose session types `/super-board | `claude` | Claude Code 2.1.302 | `/super-board run` (Workflow backend) | `claude` then `/login`; `gh auth login` | None. The reference host. | | `codex` | codex-cli 0.160.0 | `/super-board run` inside Codex runs as `--codex`; from Claude, `/super-board run --codex` | `codex login`; `gh auth login` | No Workflow tool, so every lane is headless `codex exec`. Review runs only with explicit `--codex` (a host-switched plain `run` counts). No Claude usage guard. | | `opencode` | OpenCode v2.0.22 | Not supported yet: the run halts with a host-selection request | `opencode auth login`; `gh auth login` | No OpenCode lane backend yet. `permission.external_directory: deny` is not enforced in v2.0.22 (`patch` still writes outside the repo), so OpenCode runs stay attended-only once a backend lands. | +| `agy` | Google Antigravity CLI 1.4.6 (`agy`) | Not supported yet: the run halts with a host-selection request | interactive `agy` once (headless runs use the cached sign-in); `gh auth login` | No Antigravity lane backend yet. Headless `agy -p` auto-denies every shell command and non-workspace file write that `permissions.allow` in `~/.gemini/antigravity-cli/settings.json` does not cover, and on Windows those rules match exactly unless written as `command(regex:โ€ฆ)`. | Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. @@ -19,9 +20,12 @@ Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. 2. `CODEX_THREAD_ID` or `CODEX_SESSION_ID` โ†’ `codex`. 3. `OPENCODE=1` โ†’ `opencode`. Checked before `CLAUDECODE`, which leaks into an OpenCode session started from Claude Code. `AGENT=1` is ignored: other tools set it too. -4. `CLAUDECODE` โ†’ `claude`. -5. The board config's top-level `host` key, when `--config` is given. -6. Nothing matched: empty stdout, a hint on stderr, exit 65. The run halts and asks for a +4. `ANTIGRAVITY_AGENT=1` โ†’ `agy`. Also checked before `CLAUDECODE`, which leaks into an + Antigravity lane started from a Claude session. The other `ANTIGRAVITY_*` variables are + per-conversation ids or session secrets and are not used. +5. `CLAUDECODE` โ†’ `claude`. +6. The board config's top-level `host` key, when `--config` is given. +7. Nothing matched: empty stdout, a hint on stderr, exit 65. The run halts and asks for a host. It never guesses Claude. A live env sign beats the saved config, so a Codex lane started from a Claude-configured @@ -35,3 +39,9 @@ Onboard always writes `host` into `.claude/super-board/configs/.json`. - Codex: env of `codex exec -s read-only`, 2026-10-04 (recorded in the script header). - OpenCode: env diff of an `opencode run` shell, 2026-10-19: `OPENCODE=1`, `OPENCODE_SESSION_ID`, `OPENCODE_TERMINAL=1`, `AGENT=1`. +- Antigravity: `cmd /c set` run by `run_command` inside a headless `agy -p` on agy 1.4.6, + Windows 11, 2026-10-23: `ANTIGRAVITY_AGENT=1`, `ANTIGRAVITY_CONVERSATION_ID`, + `ANTIGRAVITY_LS_VERSION=cli-1.4.6`, `ANTIGRAVITY_APP_DATA_DIR`, `ANTIGRAVITY_PROJECT_ID`, + `ANTIGRAVITY_TRAJECTORY_ID`, `ANTIGRAVITY_AGENTAPI_EXE`, `ANTIGRAVITY_LS_ADDRESS`, + `ANTIGRAVITY_CSRF_TOKEN`, `ANTIGRAVITY_SOURCE_METADATA`. No `AGENT`, `GEMINI_*` or + `CLAUDECODE`. The hooks and permissions docs name none of them. diff --git a/scripts/super-board-host.sh b/scripts/super-board-host.sh index 87900a7a..6eb7b50a 100755 --- a/scripts/super-board-host.sh +++ b/scripts/super-board-host.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# super-board-host.sh โ€” which agent host is this shell inside: `claude`, `codex` or `opencode`. +# super-board-host.sh โ€” which agent host is this shell inside: `claude`, `codex`, `opencode` or `agy`. # # WHY. `/super-board run` launched from a Codex CLI session has no Claude Workflow tool, so the # workflow backend cannot start. The orchestrator checks its own tool list first; this is the @@ -17,16 +17,23 @@ # shells it runs. Observed 2026-10-19 on opencode v2.0.22 (env diff of a `run` shell). # Checked before CLAUDECODE, which leaks into OpenCode started from a Claude session. # Not used: AGENT=1 (a generic name other tools set too). +# - Google Antigravity (the `agy` CLI) sets ANTIGRAVITY_AGENT=1 in every shell its +# run_command tool runs, plus ANTIGRAVITY_CONVERSATION_ID, ANTIGRAVITY_LS_VERSION +# (e.g. cli-1.4.6), ANTIGRAVITY_PROJECT_ID and ANTIGRAVITY_TRAJECTORY_ID. Observed +# 2026-10-23 on agy 1.4.6 (`cmd /c set` inside a headless `agy -p` run on Windows 11). +# Checked before CLAUDECODE, which leaks into an agy lane started from a Claude session. +# Not used: the ids (one per conversation) and ANTIGRAVITY_CSRF_TOKEN (a session secret). # - Claude Code sets CLAUDECODE=1. # -# ORDER. SUPER_BOARD_HOST, Codex ids, OPENCODE, CLAUDECODE, then the config's top-level -# `host` key (--config ), else exit 65. A live env sign beats the saved config. +# ORDER. SUPER_BOARD_HOST, Codex ids, OPENCODE, ANTIGRAVITY_AGENT, CLAUDECODE, then the +# config's top-level `host` key (--config ), else exit 65. A live env sign beats the +# saved config. # # Usage: super-board-host.sh [--config ] -# prints `claude`, `codex` or `opencode`, exit 0 +# prints `claude`, `codex`, `opencode` or `agy`, exit 0 # exit 65 no sign: nothing on stdout, how to choose on stderr -# exit 64 SUPER_BOARD_HOST or config `host` is not one of the three -# SUPER_BOARD_HOST=claude|codex|opencode forces the answer (tests, or a host the check +# exit 64 SUPER_BOARD_HOST or config `host` is not one of the four +# SUPER_BOARD_HOST=claude|codex|opencode|agy forces the answer (tests, or a host the check # misreads). set -euo pipefail @@ -38,23 +45,25 @@ case "${1:-}" in esac case "${SUPER_BOARD_HOST:-}" in - claude|codex|opencode) echo "$SUPER_BOARD_HOST"; exit 0 ;; + claude|codex|opencode|agy) echo "$SUPER_BOARD_HOST"; exit 0 ;; '') ;; - *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex or opencode" >&2; exit 64 ;; + *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex, opencode or agy" >&2; exit 64 ;; esac if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then echo codex elif [ -n "${OPENCODE:-}" ]; then echo opencode +elif [ -n "${ANTIGRAVITY_AGENT:-}" ]; then + echo agy elif [ -n "${CLAUDECODE:-}" ]; then echo claude elif saved=$( [ -f "$config" ] && jq -r '.host // empty' "$config" 2>/dev/null ) && [ -n "$saved" ]; then case "$saved" in - claude|codex|opencode) echo "$saved" ;; - *) echo "super-board-host.sh: config host must be claude, codex or opencode, got '$saved'" >&2; exit 64 ;; + claude|codex|opencode|agy) echo "$saved" ;; + *) echo "super-board-host.sh: config host must be claude, codex, opencode or agy, got '$saved'" >&2; exit 64 ;; esac else - echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode, or set \"host\" in the board config" >&2 + echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode|agy, or set \"host\" in the board config" >&2 exit 65 fi diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 1e494250..a92bb837 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -23,7 +23,7 @@ settings.json entries (runs the pack's install.sh) when any is missing or older - removes the folders of skills that no longer exist: super-refine, cleanup-wt, arch-loop - migrates every .claude/super-board/configs/*.json to the v3 keys (migrate-config), and - writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode) + writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode/agy) - `git init` when the folder is not a repo - adds Matt Pocock's helper skills with npx when Node is present and they are missing It never installs a system tool or signs in: those come back in `needs` with the exact @@ -59,7 +59,7 @@ import sys COLUMNS = ["Backlog", "Ready", "Building", "QA", "Review", "Blocked", "Done"] -HOSTS = ("claude", "codex", "opencode") # config `host`; super-board-host.sh accepts the same +HOSTS = ("claude", "codex", "opencode", "agy") # config `host`; super-board-host.sh accepts the same LABELS = { "qa": ("0e8a16", "Test what exists; skips Building (super-board)"), "bug": ("d73a4a", "Something is broken; built, tested, reviewed (super-board)"), diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index e4bd4bbb..74dc0e25 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -31,11 +31,12 @@ // only; the dispatcher refuses to run (exit 78) unless set here. "worker_backend": "workflow", - // Agent host this project runs on: "claude" | "codex" | "opencode". Separate from + // Agent host this project runs on: "claude" | "codex" | "opencode" | "agy" (Google + // Antigravity). Separate from // worker_backend. Onboard and setup's fix ALWAYS write it (setup fills a missing key // from --host and never replaces a set one); setup's check flags any other value. // `super-board-host.sh --config ` uses it only when SUPER_BOARD_HOST and - // every env sign (Codex ids, OPENCODE, CLAUDECODE) are absent. See docs/hosts.md. + // every env sign (Codex ids, OPENCODE, ANTIGRAVITY_AGENT, CLAUDECODE) are absent. See docs/hosts.md. "host": "claude", // Optional post-completion adapter; never bundles or installs the external skill. diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index c91e9022..fd6e8ac8 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -361,7 +361,7 @@ Write it atomically (temp file + rename) after every answer. A halt never loses โ”‚ โ”‚ {type: repo | repo+url}, repo, base_branch, timezone, columns (the seven), labels, โ”‚ โ”‚ paths, merge_policy, migrations, collect (incl. custom), notifications {channel: โ”‚ โ”‚ "session", bot_identity}, worker_backend "workflow", host (always: `bash $SB/super-board-host.sh`; - โ”‚ โ”‚ on exit 65 ask claude/codex/opencode), verify_commands (non-blank project checks). No `variant`. + โ”‚ โ”‚ on exit 65 ask claude/codex/opencode/agy), verify_commands (non-blank project checks). No `variant`. โ”‚ โ”œโ”€ .claude/super-board/active โ† โ”‚ โ”œโ”€ .gitignore += .claude/super-board/active, onboard-answers.json, onboard-staged/, โ”‚ โ”‚ backup/, inflight/, migrations/, upgrade.json (all under .claude/super-board/) diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index c63f5f08..1b3de189 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -25,8 +25,9 @@ Keep this run choice in each wave and resume command; skip only the Claude usage `--codex=` still apply); it counts as explicit `--codex`, Review included. Say so once, in the first wave report: `๐Ÿ” codex host โ€” running as --codex`. Never improvise a Claude workflow wave in Codex. -**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign) or -`opencode` (no OpenCode backend yet) halts before any claim, even with no Workflow tool, with one line: +**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign), +`opencode` (no OpenCode backend yet) or `agy` (no Antigravity backend yet) halts before any claim, +even with no Workflow tool, with one line: `โ›” unknown host โ€” set SUPER_BOARD_HOST=claude|codex and re-run`. Never guess a backend. After the same plan and claim steps, launch this command as a background task: @@ -246,8 +247,8 @@ Repeat until a done condition or halt gate fires: there is no cross-session claim at all, so never run two orchestrators (or /loop re-entries) against the same board without bot_identity. 4. **Launch** โ€” With `--codex`, or no Workflow tool, or the host check printing `codex`, use - "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65 or - `opencode` never reaches Launch (see "Unknown host halts"). Workflow tool with + "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65, + `opencode` or `agy` never reaches Launch (see "Unknown host halts"). Workflow tool with `scriptPath: .claude/workflows/super-board-wave.js` and `args: { configPath, cards, humanApprovesMerge, tier }` (`cards` hydrated after the planner: each carries `lane` and `labels`, and a `qa` card skips the Builder). Runs in the background; the diff --git a/tests/test-host.sh b/tests/test-host.sh index fb323715..fdd7d3f1 100644 --- a/tests/test-host.sh +++ b/tests/test-host.sh @@ -15,6 +15,7 @@ host() { env -i PATH="$PATH" "$@" bash "$S"; } [ "$(host SUPER_BOARD_HOST=codex)" = codex ] || fail 'override forces codex' [ "$(host SUPER_BOARD_HOST=claude CODEX_THREAD_ID=01a1)" = claude ] || fail 'override beats the env' [ "$(host SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override accepts opencode' +[ "$(host SUPER_BOARD_HOST=agy)" = agy ] || fail 'override accepts agy' rc=0; host SUPER_BOARD_HOST=gemini >/dev/null 2>&1 || rc=$? [ "$rc" = 64 ] || fail "bad override exits 64, got $rc" @@ -31,6 +32,8 @@ nohost nohost CODEX_HOME=/x CODEX_SANDBOX=seatbelt # not host signs nohost CODEX_THREAD_ID= CLAUDECODE= # empty values are not signs nohost AGENT=1 # OpenCode sets it, but so do other tools +nohost ANTIGRAVITY_CONVERSATION_ID=fe3b ANTIGRAVITY_LS_VERSION=cli-1.4.6 # ids are not the sign +nohost ANTIGRAVITY_AGENT= # empty is not a sign # OpenCode v2.0.22 exports OPENCODE=1. It wins over a CLAUDECODE leaked from a parent Claude # session; the override and a Codex id still win over it. @@ -39,15 +42,26 @@ nohost AGENT=1 # OpenCode sets it, but so do oth [ "$(host SUPER_BOARD_HOST=claude OPENCODE=1)" = claude ] || fail 'override beats the OpenCode sign' [ "$(host CODEX_THREAD_ID=01a1 OPENCODE=1)" = codex ] || fail 'a Codex id beats the OpenCode sign' +# Antigravity (agy 1.4.6) exports ANTIGRAVITY_AGENT=1 into the shells its run_command tool +# runs. It wins over a CLAUDECODE leaked from the Claude session that launched the lane; the +# override and a Codex id still win over it. +[ "$(host ANTIGRAVITY_AGENT=1)" = agy ] || fail 'ANTIGRAVITY_AGENT=1 means agy' +[ "$(host ANTIGRAVITY_AGENT=1 CLAUDECODE=1)" = agy ] || fail 'an agy lane under a Claude parent is agy' +[ "$(host SUPER_BOARD_HOST=claude ANTIGRAVITY_AGENT=1)" = claude ] || fail 'override beats the agy sign' +[ "$(host CODEX_THREAD_ID=01a1 ANTIGRAVITY_AGENT=1)" = codex ] || fail 'a Codex id beats the agy sign' + # Config `host` (top-level key): used only when no override and no env sign is present. T=$(mktemp -d); trap 'rm -f "$ERR"; rm -rf "$T"' EXIT printf '{"host":"codex"}' > "$T/codex.json" +printf '{"host":"agy"}' > "$T/agy.json" printf '{"worker_backend":"workflow"}' > "$T/none.json" printf '{"host":"gemini"}' > "$T/bad.json" cfg() { local f=$1; shift; env -i PATH="$PATH" "$@" bash "$S" --config "$f"; } [ "$(cfg "$T/codex.json")" = codex ] || fail 'config host codex prints codex' [ "$(cfg "$T/codex.json" CLAUDECODE=1)" = claude ] || fail 'an env sign beats config host' [ "$(cfg "$T/codex.json" SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override beats config host' +[ "$(cfg "$T/agy.json")" = agy ] || fail 'config host agy prints agy' +[ "$(cfg "$T/agy.json" CODEX_THREAD_ID=01a1)" = codex ] || fail 'an env sign beats config host agy' for f in none missing; do rc=0; out=$(cfg "$T/$f.json" 2>/dev/null) || rc=$? [ "$rc" = 65 ] && [ -z "$out" ] || fail "config $f without host should exit 65, got $rc '$out'" diff --git a/tests/test-setup.sh b/tests/test-setup.sh index 1aa485f3..6487b750 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -302,10 +302,10 @@ cmp -s "$GH_STATE" "$WORK/before.json" || fail "dry-run changed board state: $(c [ ! -e "$WORK/dry/.claude" ] || fail "dry-run must not write a backup file" echo "$OUT" | q '.status_backup == null and .restored == 0 and .added_columns != []' || fail "dry-run report wrong: $OUT" -# 13 โ€” config `host`: check accepts claude / codex / opencode, flags anything else, and +# 13 โ€” config `host`: check accepts claude / codex / opencode / agy, flags anything else, and # fix never overwrites a host that is already set. H="$WORK/bare/.claude/super-board/configs"; mkdir -p "$H" -for h in claude codex opencode; do +for h in claude codex opencode agy; do printf '{"host":"%s"}' "$h" > "$H/b.json" OUT=$(python3 "$SETUP" check --root "$WORK/bare" || true) echo "$OUT" | q '.host_errors == {}' || fail "host $h must be accepted: $OUT" From e622090ad7ede1ace3a932a1f064f0bbc5ab85ca Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi Date: Fri, 23 Oct 2026 19:09:25 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20[feat]=20install:=20guard=20hoo?= =?UTF-8?q?ks=20and=20permission=20rules=20for=20Google=20Antigravity=20(-?= =?UTF-8?q?-hosts=20agy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hooks/agy-hook-adapter.py translates Antigravity PreToolUse payloads, runs the unchanged guard-*.py and answers deny/ask/allow; hooks/agy-hooks.json wires it - install.sh --hosts agy: entry merged into .agents/hooks.json (other entries kept, --protect-push with --protect-main and kept on re-install, python on Windows), host detected from config/host.sh/an existing entry, no link folder, adapter not counted as a guard - scripts/super-board-agy-permissions.py writes the lane profile (command/read_file/write_file * + deny rules for dangerous forms and secret paths) or --from-allowlist regex rules; dry run, --apply with backup - tests: test-agy-hook-adapter.sh, test-agy-permissions.sh, test-install.sh scenario 25; CLAUDE.md install contract Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 5 +- hooks/agy-hook-adapter.py | 186 +++++++++++++++++++++++++ hooks/agy-hooks.json | 16 +++ install.sh | 67 ++++++++- scripts/super-board-agy-permissions.py | 151 ++++++++++++++++++++ tests/test-agy-hook-adapter.sh | 65 +++++++++ tests/test-agy-permissions.sh | 62 +++++++++ tests/test-install.sh | 27 +++- 8 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 hooks/agy-hook-adapter.py create mode 100644 hooks/agy-hooks.json create mode 100644 scripts/super-board-agy-permissions.py create mode 100644 tests/test-agy-hook-adapter.sh create mode 100644 tests/test-agy-permissions.sh diff --git a/CLAUDE.md b/CLAUDE.md index bf942a7a..e0bad0d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,13 +41,16 @@ See `skills/super-board/references/rate-limit-etiquette.md` for the full discipl ## Installation contract -`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] ` copies the pack +`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode,agy] ` copies the pack once into the target project's shared `.agents/` tree, then links each host folder into it: one link per skill under `skills/`, one per file under `bin/`, `workflows/`, `hooks/`. `.claude/` is always linked; `.codex/` and `.opencode/` only when that folder already exists, a config `host` key names it, `super-board-host.sh` reports it, or `--hosts` asks. A real-directory `.claude/` install is replaced by links on upgrade; a user's own links and anything not super-board's are left alone; where no symlink can be made the file is copied. Settings and run state stay in `.claude/`. +Host `agy` (Google Antigravity) reads `.agents/` itself and gets no links: it gets a +`super-board-guards` entry in `.agents/hooks.json` that runs `hooks/agy-hook-adapter.py`, the +same guards translated, plus a pointer to `super-board-agy-permissions.py` for headless lanes. ``` .agents/ diff --git a/hooks/agy-hook-adapter.py b/hooks/agy-hook-adapter.py new file mode 100644 index 00000000..a17e7eaa --- /dev/null +++ b/hooks/agy-hook-adapter.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Run super-board's guard hooks under Google Antigravity (the agy CLI, 2.0 and the IDE). + +Antigravity hooks live in `.agents/hooks.json` (its bundled docs: agy-customizations/docs/hooks.md) +and speak a different dialect from Claude Code: + + in {"toolCall": {"name": "run_command", "args": {"CommandLine": "..."}}, + "workspacePaths": [...], "conversationId": "...", "stepIdx": 3, ...} + out {"decision": "allow" | "deny" | "ask" | "force_ask", "reason": "..."} + +The guards (guard-*.py next to this file) read Claude's +`{"hook_event_name", "tool_name", "tool_input"}` and answer with +`hookSpecificOutput.permissionDecision = "deny"`. This adapter translates the call, runs the +guards that Claude's settings-snippet.json would run for that tool, and translates the +verdict back. The guards stay the single copy of every rule. + +Verified on agy 1.4.6 (2026-10-23): the hook fires in headless `agy -p` too, BEFORE the +permission check, and a hook that fails to start blocks every matched tool call with its +error as the reason. So the command in hooks.json must resolve: hooks run with the folder +holding hooks.json as cwd (`.agents/`), through `sh -c` on Unix and `cmd /c` on Windows, +where `python3` is a Store alias that prints "Python was not found" -- install.sh writes +`python` there. This adapter sits in `.agents/hooks/` next to the guards. + +Tool mapping. `run_command` carries `CommandLine` (observed); the file tools are mapped by +name and the first non-empty value among the usual path spellings (AbsolutePath / TargetFile +/ file_path / path ...): command/shell/terminal -> Bash, view/read/open -> Read, +grep/search/find -> Grep, write/create -> Write, replace/edit/patch/modify -> Edit. Anything +else passes. + +No objection is not the same as "allow": `allow` would skip Antigravity's own permission +check. The adapter answers + - shell commands: SB_AGY_HOOK_PASS_COMMAND (default "ask": the normal + allowlist flow; headless auto-denies what no rule allows, + see scripts/super-board-agy-permissions.py); + - files inside the workspace: SB_AGY_HOOK_PASS_FILE (default "allow": Antigravity + auto-allows workspace reads and writes anyway); + - files outside it: "ask". +The payload carries no shell cwd, only `workspacePaths`, so relative paths are resolved +against the workspace root: a lane adds its worktree from the repo root, as the brief says. + +PostToolUse returns {} (the event takes no decision); the Pre check already saw the write. +Like every guard, the adapter's own errors never block a tool call: it falls back to the +pass decision above. + +Usage (from .agents/hooks.json, cwd .agents/): + python3 hooks/agy-hook-adapter.py --event PreToolUse [--protect-push] +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +COMMAND_KEYS = ("CommandLine", "commandLine", "command", "Command", "cmd") +PATH_KEYS = ("AbsolutePath", "absolutePath", "TargetFile", "targetFile", "file_path", "filePath", + "FilePath", "path", "Path", "SearchPath", "searchPath", "DirectoryPath") +GLOB_KEYS = ("Includes", "includes", "glob", "Glob", "pattern", "Pattern", "Query", "query") +# Claude's settings-snippet.json matchers, per mapped tool. +GUARDS = { + "Bash": ["guard-worktree-path.py", "guard-delete-outside.py", "guard-secrets.py", "guard-key-literals.py"], + "Read": ["guard-secrets.py"], + "Grep": ["guard-secrets.py"], + "Write": ["guard-key-literals.py"], + "Edit": ["guard-key-literals.py"], +} + + +def first(args, keys): + for key in keys: + value = args.get(key) + if isinstance(value, list): + value = " ".join(str(v) for v in value) + if isinstance(value, str) and value.strip(): + return value + return "" + + +def other_text(args, skip): + """Every string an edit could write: all values except the path ones.""" + parts = [] + + def walk(value): + if isinstance(value, str): + parts.append(value) + elif isinstance(value, list): + for item in value: + walk(item) + elif isinstance(value, dict): + for key, item in value.items(): + if key not in skip: + walk(item) + for key, value in args.items(): + if key not in skip: + walk(value) + return "\n".join(parts) + + +def to_claude(name, args): + """(tool_name, tool_input) in Claude Code terms, or (None, None) to pass.""" + lowered = name.lower() + command = first(args, COMMAND_KEYS) + path = first(args, PATH_KEYS) + if command or re.search(r"command|shell|terminal|exec", lowered): + return "Bash", {"command": command} + if re.search(r"grep|search|find", lowered): + return "Grep", {"path": path, "glob": first(args, GLOB_KEYS)} + if re.search(r"view|read|open", lowered): + return "Read", {"file_path": path} + if re.search(r"write|create", lowered): + return "Write", {"file_path": path, "content": other_text(args, set(PATH_KEYS))} + if re.search(r"replace|edit|patch|modify", lowered): + return "Edit", {"file_path": path, "new_string": other_text(args, set(PATH_KEYS))} + return None, None + + +def inside(path, root): + if not path: + return True + try: + target = Path(path) + target = (target if target.is_absolute() else root / target).resolve() + return target == root or root in target.parents + except (OSError, ValueError): + return False + + +def run_guard(guard, payload, root): + script = HERE / guard + if not script.exists(): + return None + env = dict(os.environ, CLAUDE_PROJECT_DIR=str(root)) + proc = subprocess.run([sys.executable, str(script)], input=json.dumps(payload), text=True, + capture_output=True, cwd=str(root), env=env, timeout=25) + if proc.returncode == 2: # Claude's "block" exit, reason on stderr + return proc.stderr.strip() or f"blocked by {guard}" + try: + out = json.loads(proc.stdout or "{}").get("hookSpecificOutput") or {} + except ValueError: + return None + if out.get("permissionDecision") == "deny": + return out.get("permissionDecisionReason") or f"blocked by {guard}" + return None + + +def main(): + argv = sys.argv[1:] + event = argv[argv.index("--event") + 1] if "--event" in argv else "PreToolUse" + if event != "PreToolUse": + print("{}") + return + pass_command = os.environ.get("SB_AGY_HOOK_PASS_COMMAND", "ask") + pass_file = os.environ.get("SB_AGY_HOOK_PASS_FILE", "allow") + decision = {"decision": pass_command} + try: + payload = json.loads(sys.stdin.read() or "{}") + call = payload.get("toolCall") or {} + args = call.get("args") or {} + if isinstance(args, str): + args = json.loads(args) + roots = payload.get("workspacePaths") or [] + root = Path(roots[0] if roots else os.getcwd()).resolve() + tool, tool_input = to_claude(str(call.get("name") or ""), args if isinstance(args, dict) else {}) + if tool is None: + print(json.dumps(decision)) + return + if tool != "Bash": + decision = {"decision": pass_file if inside(tool_input.get("file_path") or tool_input.get("path"), root) else "ask"} + guards = list(GUARDS[tool]) + if tool == "Bash" and "--protect-push" in argv: + guards.append("guard-protected-push.py") + claude_payload = {"hook_event_name": "PreToolUse", "tool_name": tool, "tool_input": tool_input, + "cwd": str(root)} + for guard in guards: + reason = run_guard(guard, claude_payload, root) + if reason: + decision = {"decision": "deny", "reason": reason} + break + except Exception: + pass + print(json.dumps(decision)) + + +if __name__ == "__main__": + main() diff --git a/hooks/agy-hooks.json b/hooks/agy-hooks.json new file mode 100644 index 00000000..f606e57a --- /dev/null +++ b/hooks/agy-hooks.json @@ -0,0 +1,16 @@ +{ + "super-board-guards": { + "PreToolUse": [ + { + "matcher": "run_command|view_file|.*(command|shell|terminal|read|grep|search|find|write|create|replace|edit|patch).*", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/agy-hook-adapter.py --event PreToolUse", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/install.sh b/install.sh index fd899e6e..286e86f8 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # merges the hook settings into .claude/settings.json (backed up first, never duplicated). # # Usage: -# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] [target-project-dir] +# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode,agy] [target-project-dir] # Defaults to the current working directory. --no-hooks skips the guard hooks # and leaves settings.json untouched. --protect-main also wires the opt-in # guard that blocks direct and force pushes to main/master/base_branch; with @@ -21,6 +21,14 @@ # link cannot be made (Windows without symlink rights) the file is copied. # Settings and run state (.claude/settings.json, .claude/super-board/) stay put. # +# Google Antigravity (host `agy`) reads .agents/ itself, so it gets no folder of +# links. It gets the guard hooks instead: a "super-board-guards" entry in +# .agents/hooks.json that runs hooks/agy-hook-adapter.py (the same guard-*.py, +# translated); other entries in that file are kept, --protect-main adds the push +# guard. Asked with --hosts agy, a config "host" key, super-board-host.sh, or an +# existing entry. Headless lanes also need permission rules: +# .claude/bin/super-board-agy-permissions.py writes them (the install names it). +# # Output is grouped with emojis, one line per group of files (failures still name # the file). Environment: SUPER_BOARD_QUIET_NEXT=1 prints only the "๐Ÿ”ง installing" # group โ€” get.sh sets it and prints its own header and summary around it. @@ -68,8 +76,8 @@ HOSTS="claude" add_host() { case " $HOSTS " in *" $1 "*) ;; *) HOSTS="$HOSTS $1" ;; esac; } for h in $(echo "$ASKED_HOSTS" | tr ',' ' '); do case "$h" in - claude|codex|opencode) add_host "$h" ;; - *) echo "unknown host: $h (use claude, codex or opencode)" >&2; exit 64 ;; + claude|codex|opencode|agy) add_host "$h" ;; + *) echo "unknown host: $h (use claude, codex, opencode or agy)" >&2; exit 64 ;; esac done for h in codex opencode; do [ ! -d "$TARGET/.$h" ] || add_host "$h"; done @@ -80,11 +88,18 @@ for p in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): h = json.load(open(p)).get("host") except Exception: continue - if h in ("codex", "opencode"): + if h in ("codex", "opencode", "agy"): print(h) PY ); do add_host "$h"; done -[ "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" != codex ] || add_host codex +case "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" in + codex) add_host codex ;; + agy) add_host agy ;; +esac +# An Antigravity entry written by an earlier install keeps the host on re-install. +if [ -f "$TARGET/.agents/hooks.json" ] && grep -q '"super-board-guards"' "$TARGET/.agents/hooks.json" 2>/dev/null; then + add_host agy +fi QUIET="${SUPER_BOARD_QUIET_NEXT:-}" PACK_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION" 2>/dev/null || echo '?')" @@ -259,6 +274,7 @@ GROUPS_="skills bin workflows" [ -z "$HOOK_FILES" ] || GROUPS_="$GROUPS_ hooks" LINKED="" for host in $HOSTS; do + [ "$host" != agy ] || continue # Antigravity reads .agents/ directly: nothing to link LINKED="${LINKED:+$LINKED, }.$host" for group in $GROUPS_; do mkdir -p "$TARGET/.$host/$group" @@ -295,7 +311,8 @@ merge_settings() { } if [ "$HOOKS" -eq 1 ]; then - set -- $HOOK_FILES; n=$# + # The Antigravity adapter is plumbing that runs the guards, not a guard itself. + n=0; for h in $HOOK_FILES; do [ "$h" = agy-hook-adapter.py ] || n=$((n + 1)); done SNIPPETS="$REPO_ROOT/hooks/settings-snippet.json" if [ "$PROTECT" -eq 1 ]; then SNIPPETS="$SNIPPETS $REPO_ROOT/hooks/settings-protect-main.json"; fi # shellcheck disable=SC2086 @@ -308,6 +325,44 @@ else echo " โญ๏ธ guard hooks skipped (--no-hooks)" fi +# Antigravity: the guards reach agy lanes through .agents/hooks.json. Its hooks run +# with .agents/ as cwd (so the command is relative to it) via `cmd /c` on Windows, +# where `python3` is a Store alias that prints "Python was not found": write `python`. +case " $HOSTS " in *" agy "*) + if [ -n "$HOOK_FILES" ]; then + PY_CMD=python3 + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) PY_CMD=python ;; esac + python3 - "$REPO_ROOT/hooks/agy-hooks.json" "$TARGET/.agents/hooks.json" "$PROTECT" "$PY_CMD" <<'PY' +import json, sys +snippet, path, protect, py = json.load(open(sys.argv[1])), sys.argv[2], sys.argv[3] == "1", sys.argv[4] +try: + current = json.load(open(path)) + if not isinstance(current, dict): + raise ValueError +except FileNotFoundError: + current = {} +except ValueError: + sys.exit(f" ! {path} is not valid JSON; left untouched") +entry = snippet["super-board-guards"] +# --protect-main adds the push guard; a later install without the flag keeps it. +old = current.get("super-board-guards") or {} +had_push = any("--protect-push" in h.get("command", "") for g in old.get("PreToolUse", []) for h in g.get("hooks", [])) +for group in entry["PreToolUse"]: + for hook in group["hooks"]: + hook["command"] = hook["command"].replace("python3 ", py + " ", 1) + if (protect or had_push) and "--protect-push" not in hook["command"]: + hook["command"] += " --protect-push" +current["super-board-guards"] = entry +with open(path, "w") as f: + json.dump(current, f, indent=2) + f.write("\n") +PY + echo " ๐Ÿช Antigravity guards โ†’ .agents/hooks.json (via hooks/agy-hook-adapter.py)" + fi + echo " ๐Ÿ‘‰ Antigravity: headless lanes need permission rules once โ€” python3 .claude/bin/super-board-agy-permissions.py --apply" + ;; +esac + # AGENTS.md: a re-install refreshes the managed super-board block and touches # nothing outside its markers. No block yet โ†’ onboard writes it (with consent). if [ -f "$TARGET/AGENTS.md" ] && grep -q '^