From 1b8796505dfa8d6147dd4b346ec5815f64703ccc Mon Sep 17 00:00:00 2001 From: Radwuan Abouzeid Date: Sun, 6 Sep 2026 04:25:59 -0500 Subject: [PATCH 1/4] feat(antigravity): warn when a foreign PostToolUse hook shadows the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy dispatches a single named hook per event instead of merging them, despite its own docs promising a merge. A user who already has a PostToolUse hook in ~/.gemini/config/hooks.json can therefore install the adapter, see the toggle read "on", and never receive a single row — the failure is completely silent from inside the app. We cannot fix agy's dispatch, so stop the user debugging it blind: antigravity_hooks_shadowed() reports when our hook is installed *and* some key other than `logic-loop` registers PostToolUse. "Ours" is matched by the --antigravity-hook command fingerprint as well as by the owned key, so a hand-copied or renamed Logic Loop block never warns about itself. Both the grouped (matcher + hooks) and flat entry shapes are probed, since a foreign hook may use either. Detection only — the foreign entry is never rewritten or removed, keeping install/remove byte-identically reversible. Fails open on every axis: missing, unreadable and malformed hooks.json all return false, as does any shape the check does not recognize, and the command returns bool rather than Result so there is no error for the UI to handle. The warning reuses the side panel's existing tailer-failed strip rather than inventing a second warning style. AgentStatusBar drives the check because only it knows when the toggle flips; a startup-only check would go stale the moment the user turns agy on. Closes #9 --- README.md | 3 +- src-tauri/src/antigravity.rs | 140 +++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src/App.tsx | 8 +- src/components/AgentStatusBar.tsx | 32 ++++++- src/components/SidePanel.tsx | 19 ++++ src/lib/ingest.ts | 8 ++ 7 files changed, 206 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0a9d545..b6ad9ad 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ from this side (full derivation in [docs/TESTING.md](docs/TESTING.md) §21): - `agy` doesn't merge multiple named `PostToolUse` hooks despite documenting that it does. If you already have your own `PostToolUse` hook in `~/.gemini/config/hooks.json`, Logic Loop's may never fire — the toggle - will still read "on". Check for a foreign hook first if no rows appear. + will still read "on". Logic Loop detects that case and says so in the side + panel; it never edits the foreign hook — merging or removing it is yours. ## Status diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs index 9e9915a..e3e8f91 100644 --- a/src-tauri/src/antigravity.rs +++ b/src-tauri/src/antigravity.rs @@ -17,6 +17,13 @@ const HOOK_NAME: &str = "logic-loop"; /// on a Post* event can only delay, never block or deny. const ANTIGRAVITY_HOOK_EVENTS: [&str; 3] = ["PostToolUse", "PostInvocation", "Stop"]; +/// Command-string fingerprint of a Logic Loop entry, the way `ingest::MARKER` +/// fingerprints ours inside Claude's flat per-event arrays. `HOOK_NAME` is the +/// primary identity; this exists so the shadow check below cannot mistake a +/// Logic Loop registration filed under some *other* key (hand-copied, or a key +/// the user renamed) for a stranger and warn about ourselves. +const HOOK_FLAG: &str = "--antigravity-hook"; + fn home() -> String { std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) } @@ -56,7 +63,7 @@ fn shell_single_quote(s: &str) -> String { fn command_for(event: &str) -> Result { let exe = std::env::current_exe().map_err(|e| e.to_string())?; - Ok(format!("{} --antigravity-hook {event}", shell_single_quote(&exe.to_string_lossy()))) + Ok(format!("{} {HOOK_FLAG} {event}", shell_single_quote(&exe.to_string_lossy()))) } fn strip_ours(settings: &mut serde_json::Value) { @@ -89,6 +96,48 @@ fn hooks_status_from(settings: &serde_json::Value) -> bool { settings.get(HOOK_NAME).is_some() } +/// Does a hook *other* than ours claim `PostToolUse`? +/// +/// `agy` dispatches a single named hook per event rather than merging them, +/// despite its own docs promising a merge (README caveats, docs/TESTING.md +/// §21). A pre-existing foreign `PostToolUse` registration can therefore win +/// the dispatch outright: our entry is installed, `antigravity_hooks_status` +/// reads true, the toggle reads "on", and not one row ever lands. Detection +/// only — the foreign entry is never rewritten or dropped, because removal has +/// to stay byte-identically reversible (`remove_restores_original`). +/// +/// Both entry shapes are probed: grouped (`matcher` + `hooks` wrapper, what +/// `apply_setup` writes for `PostToolUse`) and flat (a bare handler). A foreign +/// hook may use either, and assuming the shape we happen to write would miss +/// half the shadowing cases. Anything else — a non-object root, a +/// `PostToolUse` that is not an array — reads as "nothing to say": this runs +/// against arbitrary third-party config, so an unrecognized shape must stay +/// silent rather than guess. +fn shadowing_post_tool_use(settings: &serde_json::Value) -> bool { + fn handler_is_ours(handler: &serde_json::Value) -> bool { + handler + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(HOOK_FLAG) || c.contains(crate::ingest::MARKER)) + } + fn entry_is_ours(entry: &serde_json::Value) -> bool { + handler_is_ours(entry) + || entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hs| hs.iter().any(handler_is_ours)) + } + settings.as_object().is_some_and(|obj| { + obj.iter().any(|(name, hook)| { + name.as_str() != HOOK_NAME + && hook + .get("PostToolUse") + .and_then(|v| v.as_array()) + .is_some_and(|entries| entries.iter().any(|e| !entry_is_ours(e))) + }) + }) +} + fn is_executable(candidate: &std::path::Path) -> bool { #[cfg(unix)] { @@ -140,6 +189,20 @@ pub fn antigravity_hooks_status() -> Result { Ok(hooks_status_from(&settings)) } +/// Whether our installed `PostToolUse` hook is being shadowed by a foreign +/// one — the failure the side panel's warning strip exists for. Deliberately +/// `bool`, not `Result`: a missing, unreadable or malformed `hooks.json` means +/// "say nothing and carry on", not an error the caller has to render. Reports +/// only while our hook is actually installed, so the warning cannot fire at a +/// user who never turned the adapter on. +#[tauri::command] +pub fn antigravity_hooks_shadowed() -> bool { + let Ok(settings) = read_settings() else { + return false; + }; + hooks_status_from(&settings) && shadowing_post_tool_use(&settings) +} + /// Remap Antigravity's native camelCase hook payload into the canonical /// snake_case wire shape every other adapter already produces. Pure and /// total — never panics, never fails; a field it can't find is just absent @@ -296,6 +359,81 @@ mod tests { assert_eq!(s, serde_json::json!({})); } + #[test] + fn shadow_check_silent_when_there_is_no_hooks_file() { + // `read_settings` maps NotFound to `{}`, so this is the exact value the + // command sees for a user who has never configured agy at all. + assert!(!shadowing_post_tool_use(&serde_json::json!({}))); + } + + #[test] + fn shadow_check_silent_when_only_our_hook_is_installed() { + let mut s = serde_json::json!({}); + apply_setup(&mut s).unwrap(); + assert!(hooks_status_from(&s)); + assert!(!shadowing_post_tool_use(&s), "our own entry must never read as foreign"); + } + + #[test] + fn shadow_check_sees_a_foreign_post_tool_use_hook() { + let s = foreign_settings(); + assert!(shadowing_post_tool_use(&s)); + // Nothing of ours to shadow yet — `antigravity_hooks_shadowed` gates on + // this before it ever asks, so an uninstalled adapter stays quiet. + assert!(!hooks_status_from(&s)); + } + + #[test] + fn shadow_check_fires_when_ours_and_a_foreign_hook_coexist() { + // The silent failure this whole check exists for: agy dispatches only + // lint-checker, the toggle still reads "on", zero rows ever land. + let mut s = foreign_settings(); + apply_setup(&mut s).unwrap(); + assert!(hooks_status_from(&s)); + assert!(shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_ignores_foreign_hooks_on_other_events() { + // Only PostToolUse carries our tool rows; a stranger on Stop shadows + // less than the whole adapter and must not cry wolf. + let s = serde_json::json!({ "greeter": { "Stop": [{ "type": "command", "command": "say done" }] } }); + assert!(!shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_sees_the_flat_foreign_entry_shape_too() { + // agy accepts a bare handler as well as the grouped matcher wrapper we + // write; a foreign hook in that shape shadows ours just as hard. + let s = serde_json::json!({ "linter": { "PostToolUse": [{ "type": "command", "command": "./lint.sh" }] } }); + assert!(shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_does_not_warn_about_our_own_command_under_another_key() { + // A hand-copied or renamed Logic Loop block is still ours — warning + // about it would send the user hunting for a hook that isn't there. + let s = serde_json::json!({ + "logic-loop-old": { + "PostToolUse": [{ + "matcher": "*", + "hooks": [{ "type": "command", "command": "'/Applications/Logic Loop.app/x' --antigravity-hook PostToolUse" }] + }] + } + }); + assert!(!shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_stays_silent_on_shapes_it_does_not_recognize() { + // hooks.json is arbitrary third-party content. A malformed root or a + // PostToolUse that isn't an array must read as "nothing to say" rather + // than as a warning nobody can act on. + assert!(!shadowing_post_tool_use(&serde_json::json!("nonsense"))); + assert!(!shadowing_post_tool_use(&serde_json::json!({ "linter": { "PostToolUse": "./lint.sh" } }))); + assert!(!shadowing_post_tool_use(&serde_json::json!({ "linter": 7 }))); + } + #[test] fn translate_maps_common_fields() { let input = serde_json::json!({ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e73048..b1ed36a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -249,6 +249,7 @@ pub fn run() { antigravity::antigravity_hooks_setup, antigravity::antigravity_hooks_remove, antigravity::antigravity_hooks_status, + antigravity::antigravity_hooks_shadowed, extractor::run_extractor, clipboard::clipboard_text, clipboard::clipboard_image_path diff --git a/src/App.tsx b/src/App.tsx index c0d29e8..f5817e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,11 @@ export default function App() { // Sessions whose transcript file could not be opened — they emit hooks but no // transcript, so decisions never extract for them. Silent until surfaced. const [blindSessions, setBlindSessions] = useState>({}); + // Same silence, one layer earlier: the agy adapter is installed but a foreign + // PostToolUse hook can win agy's single-hook dispatch, so no event ever + // arrives. Owned here because the toggle (AgentStatusBar) and the warning + // strip (SidePanel) are siblings. + const [antigravityShadowed, setAntigravityShadowed] = useState(false); // Nudges (Phase 6): muted project keys, cached so the hot ingestion path // never blocks on a DB read before deciding whether to notify. @@ -949,6 +954,7 @@ export default function App() { accent={activeTab.color === PALETTE[7] ? null : activeTab.color} refreshKey={panelRefresh} blindPaths={Object.values(blindSessions)} + antigravityShadowed={antigravityShadowed} fanOut={fanOutRollups} onSelectTab={setActiveId} onDismissMember={dismissSpawnMember} @@ -959,7 +965,7 @@ export default function App() { /> )}
- +
{tabs.map((tab) => ( void; +} + /** Header row above the terminal pane, lined up with SidePanel's own * "project:/notify" header on the left. Was previously crammed into * BookmarksBar alongside bookmarks — grows with every adapter (Phase 8 * added "opencode", more coming per ROADMAP.md v2 Adapters), and bookmarks * grow without bound too, so the two don't belong on the same row. */ -export function AgentStatusBar() { +export function AgentStatusBar({ onAntigravityShadowed }: Props) { const [hooksOn, setHooksOn] = useState(null); const [opencodeAvailable, setOpencodeAvailable] = useState(false); const [opencodeOn, setOpencodeOn] = useState(null); @@ -35,6 +44,18 @@ export function AgentStatusBar() { const [extractor, setExtractor] = useState(null); const [showSettings, setShowSettings] = useState(false); + // Fail open like every other ingestion-side check: an unreadable hooks.json + // clears the warning rather than surfacing an error. + const refreshShadowed = (installed: boolean | null) => { + if (!installed) { + onAntigravityShadowed(false); + return; + } + void antigravityHooksShadowed() + .then(onAntigravityShadowed) + .catch(() => onAntigravityShadowed(false)); + }; + useEffect(() => { void hooksStatus().then(setHooksOn).catch(() => setHooksOn(null)); void getExtractorSettings().then(setExtractor).catch(() => undefined); @@ -54,7 +75,12 @@ export function AgentStatusBar() { .then((available) => { setAntigravityAvailable(available); if (available) - void antigravityHooksStatus().then(setAntigravityOn).catch(() => setAntigravityOn(null)); + void antigravityHooksStatus() + .then((on) => { + setAntigravityOn(on); + refreshShadowed(on); + }) + .catch(() => setAntigravityOn(null)); }) .catch(() => setAntigravityAvailable(false)); }, []); @@ -111,9 +137,11 @@ export function AgentStatusBar() { if (antigravityOn) { await antigravityHooksRemove(); setAntigravityOn(false); + refreshShadowed(false); } else { await antigravityHooksSetup(); setAntigravityOn(true); + refreshShadowed(true); } } catch (e) { console.error("antigravity hooks toggle failed:", e); diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index fb5954e..18a93a0 100644 --- a/src/components/SidePanel.tsx +++ b/src/components/SidePanel.tsx @@ -37,6 +37,7 @@ interface Props { accent: string | null; // matching bookmark's color, if the project is bookmarked refreshKey: number; // bump to force reload (new events / blocker changes) blindPaths: string[]; // transcripts that failed to open — panels are incomplete + antigravityShadowed: boolean; // a foreign PostToolUse hook can swallow the agy adapter's events fanOut: FanOutRollup[]; // every fan-out group the active tab belongs to (as parent, possibly several; as child, at most one), oldest first onSelectTab: (id: string) => void; // jump to a fan-out child/parent tab onDismissMember: (groupId: string, childTabId: string) => void; // drop a lingering row from the fan-out rollup @@ -89,6 +90,7 @@ export function SidePanel({ accent, refreshKey, blindPaths, + antigravityShadowed, fanOut, onSelectTab, onDismissMember, @@ -556,6 +558,23 @@ export function SidePanel({

)} + {/* Same strip, same failure class: the adapter is installed and the + toggle reads "on", but `agy` dispatches a single named PostToolUse + hook instead of merging them, so a foreign hook can swallow every + event and the panels just look like a quiet day. Detection only — + the user's own hook is never touched. */} + {antigravityShadowed && ( +

+ ⚠ another PostToolUse hook may be shadowing the antigravity adapter +

+ )} {childStrip && (

{ return invoke("antigravity_hooks_status"); } +/** True when our hook is installed but a foreign `PostToolUse` hook in + * ~/.gemini/config/hooks.json can shadow it — `agy` dispatches one named hook + * per event instead of merging, so the adapter reads "on" and never fires. + * Detection only, and false for any unreadable/malformed config. */ +export function antigravityHooksShadowed(): Promise { + return invoke("antigravity_hooks_shadowed"); +} + export function onHookEvent(cb: (p: HookPayload) => void): Promise { return listen("ingest://hook", (e) => { if (typeof e.payload?.hook_event_name === "string" && typeof e.payload?.session_id === "string") { From b4d2b3f151edb2ba93c2c7389086ef636918f2de Mon Sep 17 00:00:00 2001 From: Superlogicai Date: Sun, 6 Sep 2026 11:49:24 -1000 Subject: [PATCH 2/4] fix(antigravity): reference HOOK_FLAG from main.rs, recheck shadow warning on focus, drop dead MARKER branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on PR #19: - HOOK_FLAG is now pub (not pub(crate) — main.rs is the binary crate, this module lives in app_lib, pub(crate) doesn't cross that boundary), and main.rs's headless-hook dispatch matches against it instead of a literal "--antigravity-hook" string, so the two can no longer drift. - The shadow-hook warning previously only rechecked on mount and on toggle flip, so a user who fixed the foreign hook by hand (exactly what the warning tells them to do) never saw it clear. Now rechecks on window focus and a 15s interval, with a sequence ref so a rapid on/off toggle can't leave a stale result from an in-flight check. - Dropped handler_is_ours's dead ingest::MARKER branch — nothing writes that Claude-specific marker into an antigravity hooks.json command. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E9BsJXxSYH1VdKpzYBZNWf --- src-tauri/src/antigravity.rs | 8 ++++-- src-tauri/src/main.rs | 2 +- src/components/AgentStatusBar.tsx | 45 +++++++++++++++++++------------ 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs index e3e8f91..56c8c60 100644 --- a/src-tauri/src/antigravity.rs +++ b/src-tauri/src/antigravity.rs @@ -22,7 +22,11 @@ const ANTIGRAVITY_HOOK_EVENTS: [&str; 3] = ["PostToolUse", "PostInvocation", "St /// primary identity; this exists so the shadow check below cannot mistake a /// Logic Loop registration filed under some *other* key (hand-copied, or a key /// the user renamed) for a stranger and warn about ourselves. -const HOOK_FLAG: &str = "--antigravity-hook"; +/// Plain `pub`, not `pub(crate)`: `main.rs` is the binary crate (`app`), this +/// module lives in the library crate (`app_lib`), and `pub(crate)` doesn't +/// cross that boundary. `main.rs`'s headless-hook dispatch matches against +/// this same constant so the two can't drift. +pub const HOOK_FLAG: &str = "--antigravity-hook"; fn home() -> String { std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) @@ -118,7 +122,7 @@ fn shadowing_post_tool_use(settings: &serde_json::Value) -> bool { handler .get("command") .and_then(|c| c.as_str()) - .is_some_and(|c| c.contains(HOOK_FLAG) || c.contains(crate::ingest::MARKER)) + .is_some_and(|c| c.contains(HOOK_FLAG)) } fn entry_is_ours(entry: &serde_json::Value) -> bool { handler_is_ours(entry) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7912c03..c950189 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,7 +5,7 @@ fn main() { // Antigravity spawns this exact binary as its hook `command`; intercept // before booting the GUI so `agy` gets a headless process, not a window. let args: Vec = std::env::args().collect(); - if let Some(pos) = args.iter().position(|a| a == "--antigravity-hook") { + if let Some(pos) = args.iter().position(|a| a == app_lib::antigravity::HOOK_FLAG) { if let Some(event) = args.get(pos + 1) { app_lib::antigravity::run_hook_mode(event); } diff --git a/src/components/AgentStatusBar.tsx b/src/components/AgentStatusBar.tsx index c3bca43..cb283f1 100644 --- a/src/components/AgentStatusBar.tsx +++ b/src/components/AgentStatusBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { antigravityDetect, antigravityHooksRemove, @@ -44,17 +44,35 @@ export function AgentStatusBar({ onAntigravityShadowed }: Props) { const [extractor, setExtractor] = useState(null); const [showSettings, setShowSettings] = useState(false); - // Fail open like every other ingestion-side check: an unreadable hooks.json - // clears the warning rather than surfacing an error. - const refreshShadowed = (installed: boolean | null) => { - if (!installed) { + // A user who fixes the foreign hook by hand — exactly what the warning + // strip's own text tells them to do — never touches the toggle, so a + // mount/toggle-only check would never clear. Recheck on window focus (the + // moment they're most likely to come back from editing hooks.json) plus a + // slow interval as a backstop for a session that never loses focus. The + // sequence ref drops a stale in-flight result from a check that started + // before the toggle flipped again, closing the rapid on/off race. + const shadowSeq = useRef(0); + useEffect(() => { + if (!antigravityOn) { onAntigravityShadowed(false); return; } - void antigravityHooksShadowed() - .then(onAntigravityShadowed) - .catch(() => onAntigravityShadowed(false)); - }; + const check = () => { + const seq = ++shadowSeq.current; + void antigravityHooksShadowed() + .catch(() => false) + .then((shadowed) => { + if (seq === shadowSeq.current) onAntigravityShadowed(shadowed); + }); + }; + check(); + window.addEventListener("focus", check); + const interval = setInterval(check, 15_000); + return () => { + window.removeEventListener("focus", check); + clearInterval(interval); + }; + }, [antigravityOn]); useEffect(() => { void hooksStatus().then(setHooksOn).catch(() => setHooksOn(null)); @@ -75,12 +93,7 @@ export function AgentStatusBar({ onAntigravityShadowed }: Props) { .then((available) => { setAntigravityAvailable(available); if (available) - void antigravityHooksStatus() - .then((on) => { - setAntigravityOn(on); - refreshShadowed(on); - }) - .catch(() => setAntigravityOn(null)); + void antigravityHooksStatus().then(setAntigravityOn).catch(() => setAntigravityOn(null)); }) .catch(() => setAntigravityAvailable(false)); }, []); @@ -137,11 +150,9 @@ export function AgentStatusBar({ onAntigravityShadowed }: Props) { if (antigravityOn) { await antigravityHooksRemove(); setAntigravityOn(false); - refreshShadowed(false); } else { await antigravityHooksSetup(); setAntigravityOn(true); - refreshShadowed(true); } } catch (e) { console.error("antigravity hooks toggle failed:", e); From ef88a5a6186ffd53c6591ec256bc32c0d236c746 Mon Sep 17 00:00:00 2001 From: Superlogicai Date: Sun, 6 Sep 2026 12:03:44 -1000 Subject: [PATCH 3/4] chore: retrigger CI From 8b14fd862b380d916b70bb3e32d78c783d3ce80a Mon Sep 17 00:00:00 2001 From: Superlogicai Date: Sun, 6 Sep 2026 13:06:53 -1000 Subject: [PATCH 4/4] chore: retrigger CI (2)