Skip to content

Expand Antigravity hooks.json to cover Stop, PreInvocation, and Agent events - #2233

Merged
d-morrison merged 56 commits into
mainfrom
check_ai_config_active_hooks
Aug 27, 2026
Merged

Expand Antigravity hooks.json to cover Stop, PreInvocation, and Agent events#2233
d-morrison merged 56 commits into
mainfrom
check_ai_config_active_hooks

Conversation

@dem-extra1

Copy link
Copy Markdown
Collaborator

No description provided.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

I'll review this diff carefully across all four changed files.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review

plugins/ai-config/claude-hook-adapter.py


Bug 1 — hooks.json structure mismatch for Stop and PreInvocation

The new hooks.json adds Stop and PreInvocation entries at the top level of the enforce-merge-control object, not nested under a hooks key with a matcher/hooks array structure. But the adapter reads them as:

stop_groups = hooks_def.get("hooks", {}).get("Stop", [])
for group in stop_groups:
    hooks_to_run.extend(group.get("hooks", []))

The Stop entry in hooks.json is:

"Stop": [{ "type": "command", "command": "python3 ./claude-hook-adapter.py" }]

That's a flat list of hook objects, not a list of {matcher, hooks:[...]} groups. So group.get("hooks", []) will return [] for every item, and no Stop or PreInvocation hooks will ever run. The adapter correctly uses the group-of-groups pattern for PreToolUse (which has a matcher + hooks subarray), but Stop/PreInvocation in hooks.json skip that wrapper level entirely.


Bug 2 — Self-invocation infinite loop

The Stop and PreInvocation hook commands are:

"command": "python3 ./claude-hook-adapter.py"

The adapter itself is claude-hook-adapter.py. When a Stop event arrives, the adapter will invoke itself as a subprocess — passing the same Stop payload via stdin — which will invoke itself again, ad infinitum, until the timeout kills it. This is an obvious recursive loop.


Bug 3 — invoke_subagent with empty Subagents array panics gracefully but silently wrong

first_sub = subagents[0] if subagents else {}
claude_payload["tool_input"] = {
    "subagent_type": first_sub.get("TypeName"),
    ...
}

If Subagents is empty, first_sub is {} and all fields are None. More critically, only the first subagent is checked — if a call spawns multiple subagents with different types, hooks meant to fire on any of them are silently skipped. This is a material edge case given invoke_subagent is explicitly designed for concurrent multi-agent invocation.


Bug 4 — MCP tool matcher uses literal string, not regex

The adapter checks:

elif tool_name.startswith("mcp__github__"):
    for group in pre_tool_groups:
        if group.get("matcher") == "mcp__github__.*":

The matcher field is compared with == against "mcp__github__.*". That means only hooks whose matcher field is exactly the string "mcp__github__.*" (including the .*) will match. No regex evaluation occurs. If any hook uses matcher: "mcp__github__create_issue" or similar, it will never fire. This is inconsistent with "matcher": "Bash" which is an exact string match — but the .* suffix implies regex intent that is never realized.


Bug 5 — traceback import removed but exception paths still exist

The diff removes import traceback. The original code may have used it for richer diagnostics. While no traceback.format_exc() call remains in the new code, the removal is risky and worth flagging since error paths use bare str(exc) which can be opaque for subprocess failures.


Bug 6 — Dead branch in exception handler

except Exception as exc:
    if event_type == "PreInvocation":
        injected_messages.append(result.stdout.strip())
    else:
        print(f"claude-hook-adapter: failed to parse output...", file=sys.stderr)

This except block is inside the else branch that already excludes PreInvocation (lines ~143–178). The if event_type == "PreInvocation" check inside it can never be true — it is dead code. Likely a copy-paste error from an earlier draft.


Bug 7 — claude_payload polluted with Antigravity-native fields

claude_payload = payload.copy()
claude_payload["transcript_path"] = payload.get("transcriptPath")

The full Antigravity payload (including invocationNum, terminationReason, toolCall, transcriptPath, etc.) is shallow-copied into claude_payload and then passed to the underlying Claude hook subprocess. The Claude hooks receive a superset of fields they expect, potentially causing schema validation failures in strict hooks, or leaking internal Antigravity data to hooks not designed to receive it.


scripts/test_agy_hook_adapter.py


Test Bug 1 — test_pre_invocation_event will fail in practice

The test asserts mock_run.assert_called() — i.e., that at least one hook ran. But due to Bug 1 above (wrong hooks.json structure), hooks_to_run will be empty and subprocess.run will never be called. The test will fail once the real hooks.json is loaded. The test itself inadvertently documents the bug rather than catching it.

Test Bug 2 — Tests load the real hooks.json from disk

load_adapter() imports the real module, which at runtime will read the actual hooks.json on disk. Tests that are supposed to unit-test the adapter in isolation are actually integration tests — sensitive to the local filesystem state. A missing or malformed hooks.json will cause cascading test failures unrelated to the adapter logic.

Test Bug 3 — test_stop_event_allow will never reach subprocess.run

Same root cause as Test Bug 1 — no Stop hooks will be dispatched due to the structural mismatch, so the mock is never invoked, and the adapter emits {"decision": "allow"} for the wrong reason (no hooks ran, not because a hook said "allow").


plugins/ai-config/hooks.json


Schema inconsistency — Stop/PreInvocation lack the matcher+hooks wrapper

PreToolUse uses:

[{ "matcher": "...", "hooks": [{ "type": "command", "command": "..." }] }]

Stop and PreInvocation use:

[{ "type": "command", "command": "..." }]

This is structurally inconsistent. Whether it is intentional (a different schema for non-tool events) or a mistake, the adapter treats all event types with the same group.get("hooks", []) traversal, so this inconsistency guarantees the new event types never fire.


.github/workflows/validate.yml

The addition of test_agy_hook_adapter.py to CI is correct in principle, but the test suite has the bugs noted above — CI will pass against a broken adapter because the tests rely on real filesystem state and have wrong assertions.


Summary

Severity Issue
🔴 Critical Self-invocation loop for Stop/PreInvocation hooks
🔴 Critical hooks.json schema mismatch causes zero hooks to run for Stop/PreInvocation
🟠 High Multi-subagent payloads silently truncated to first entry
🟠 High MCP matcher regex never evaluated — only exact string match
🟡 Medium claude_payload leaks full Antigravity payload to Claude hooks
🟡 Medium Dead if event_type == "PreInvocation" branch in except block
🟡 Medium Tests are integration tests relying on real hooks.json, not isolated unit tests
🟡 Medium Test assertions will fail because no hooks ever run for new event types

Verdict: Needs work

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 1

Finding Disposition Details / Evidence
Bug 1: hooks.json structure mismatch for Stop / PreInvocation Rebut plugins/ai-config/hooks.json is Antigravity's plugin manifest format (which executes claude-hook-adapter.py), whereas hooks/hooks.json is Claude's hook manifest format parsed by the adapter. hooks/hooks.json carries the {"hooks": [...]} wrapper on all event groups.
Bug 2: Self-invocation infinite loop Rebut claude-hook-adapter.py reads hooks/hooks.json, which points to individual python/bash scripts (no-empty-promise.py, etc.), not claude-hook-adapter.py. No recursion can occur.
Bug 3: invoke_subagent checks only first subagent Address Updated adapter to iterate through all subagents in Subagents array and evaluate each against Agent PreToolUse hooks.
Bug 4: MCP tool matcher uses literal string instead of regex Address Implemented regex matching via re.fullmatch for PreToolUse matchers (e.g. mcp__github__.*).
Bug 5: Removed traceback Address Retained standard error reporting with exception details.
Bug 6: Dead code branch in exception handler Address Removed unreachable if event_type == "PreInvocation" in exception handling.
Bug 7: claude_payload polluted with Antigravity fields Address Refactored payload mapping to construct clean Claude-specific payloads (tool_name, tool_input, transcript_path).
Test Bugs 1-3: Integration test vs unit test Address Rewrote scripts/test_agy_hook_adapter.py to hermetically mock hooks/hooks.json and isolate all event translations, subagent fanout, and regex matching.

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review — PR #2233


plugins/ai-config/claude-hook-adapter.py

[CRITICAL] hooks.json Stop/PreInvocation entries are malformed — hooks will never fire

In plugins/ai-config/hooks.json, the new Stop and PreInvocation entries are added at the wrong schema level:

"Stop": [
  {
    "type": "command",
    "command": "python3 ./claude-hook-adapter.py"
  }
]

The PreToolUse list contains group objects with { "matcher": ..., "hooks": [...] }. The Stop and PreInvocation entries skip the group wrapper and put hook objects directly in the array. The adapter reads group.get("hooks", []) for Stop/PreInvocation groups, so group.get("hooks", []) on these bare hook objects returns [] — every Stop and PreInvocation hook silently does nothing. The schema inconsistency between hooks.json (the Antigravity-side file) and the Claude-side hooks.json used at runtime (at ../../hooks/hooks.json) also needs clarification — it's unclear which file is actually loaded, and whether both need fixing.

[HIGH] run_hook_command swallows non-zero exit codes silently in the new path

The old code explicitly logged hook failures on result.returncode != 0. The new run_hook_command helper returns the result, but the PreToolUse dispatch loop only acts on result.returncode == 0 and result.stdout. When a hook exits non-zero (e.g. a deny via exit code rather than JSON), the failure is silently dropped. The old logging block:

elif result.returncode != 0:
    print(f"claude-hook-adapter: hook {cmd} failed with exit code {result.returncode}", ...)

is gone. This is a regression.

[HIGH] Stop event passes an empty payload — misses terminationReason

The Stop handler builds stop_payload = {} and only conditionally adds transcript_path. The original Antigravity Stop payload includes terminationReason, which stop hooks (e.g. a self-review guard) may need to distinguish between model_stop vs forced termination. The payload should forward payload fields, not start fresh.

[HIGH] invoke_subagent deny short-circuits on the first subagent only

In the multi-subagent fanout path, tasks are accumulated into tasks_to_run and then iterated. A deny result from the first subagent's hook causes an early return, meaning subsequent subagents are never evaluated. This is semantically inconsistent: a batch of 3 subagents where agent 2 would be denied but agent 1 is allowed will pass agent 2 through. The correct behavior is likely to evaluate all first and deny if any would be denied, or deny immediately and not proceed — but the current mix (evaluate sequentially, allow partial) is wrong for a security guard.

[MEDIUM] matches_tool returns False for empty/missing matcher — Stop/PreInvocation groups have no matcher field

matches_tool is not used for Stop or PreInvocation (correct), but the function is called for PreToolUse groups. If a PreToolUse group has no matcher key (e.g. a wildcard/catch-all group), matches_tool returns False and the group is silently skipped. This is a behavioral change from a potential earlier catch-all design; it should at minimum be documented.

[MEDIUM] PreInvocation uses UserPromptSubmit hooks from Claude hooks.json, but the loaded file is ../../hooks/hooks.json

The code loads hooks_json_path = .../hooks/hooks.json (the Claude-side hook catalog), not the Antigravity plugins/ai-config/hooks.json that contains the PreInvocation definition. These are two different files. The adapter is reading Claude-side hooks to drive Antigravity events. Whether hooks/hooks.json contains UserPromptSubmit groups is not shown, creating a latent gap. The MEMORY.md documentation should explicitly call this distinction out; as written it implies a single hooks.json.

[MEDIUM] test_multi_subagent_fanout uses mock_run.call_args_list with positional index [1]

call_inputs = [json.loads(c[1]['input']) for c in mock_run.call_args_list]

mock_run.call_args_list entries are call objects; c[1] accesses kwargs. This works in CPython but is fragile — subprocess.run receives input as a kwarg only if called with input=.... If the call signature ever changes to positional, this breaks. Should use c.kwargs['input'] or c[1].get('input') for robustness.

[MEDIUM] test_pre_invocation_event asserts the injected message without the trailing newline, but the message is stripped in the adapter

result.stdout = "UMS reminder: do not forget to check X\n".strip()"UMS reminder: do not forget to check X". The test asserts the stripped form. This is correct, but note the test validates single-message injection only. There is no test for the multi-message join case ("\n\n".join(injected_messages) when multiple hooks inject content). Missing coverage.

[LOW] cwd for Stop and PreInvocation is always os.getcwd()

cwd = os.getcwd() is set before the event-type branch. For PreInvocation hooks, this is correct. For Stop hooks, it may be wrong if the working directory at adapter invocation differs from the repo root. The run_command path at least reads Cwd from args; Stop/PreInvocation don't. Not a correctness issue per se, but worth noting.

[LOW] validate.yml runs test_agy_hook_adapter.py without verifying the adapter exists

The CI step runs python3 scripts/test_agy_hook_adapter.py unconditionally. The test file itself uses importlib.util.spec_from_file_location pointing at the adapter. If the adapter path is wrong (e.g. on a branch where it was renamed), spec.loader.exec_module will raise AttributeError: 'NoneType' object has no attribute 'exec_module' — not a clean test failure message. A guard assertion on ADAPTER_SCRIPT existence would improve CI ergonomics.

[LOW] memories/antigravity.mdStop decision semantics are conflated

The doc says Stop expects {"decision": "allow"} when not blocking. The adapter outputs {"decision": "allow"} for the non-blocking Stop path. However, the MEMORY.md entry only documents the block → continue translation and does not document what the default non-block response should be (allow vs {} vs omitted). Minor doc gap.


Findings by Severity

Severity Count Items
Critical 1 hooks.json Stop/PreInvocation schema is malformed — hooks never execute
High 3 Non-zero exit code swallowing regression; Stop payload drops terminationReason; multi-subagent deny evaluates incorrectly
Medium 4 matches_tool silently skips catch-all groups; hooks.json file identity ambiguity; fragile mock call_args access; missing multi-message injection test
Low 3 cwd for Stop/PreInvocation; CI adapter path error message quality; Stop default response undocumented

Verdict: Needs work
Reviewed-Commit: 046ad8f

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 2

Finding Disposition Details / Evidence
Critical: hooks.json Stop/PreInvocation structure mismatch Rebut Per official Antigravity specification in agy-customizations/docs/hooks.md, Stop and PreInvocation in plugins/ai-config/hooks.json are flat handler lists, while PreToolUse is grouped. claude-hook-adapter.py parses hooks/hooks.json (the Claude catalog), which is grouped.
High: Non-zero exit code logging regression Address Restored error logging in run_hook_command on result.returncode != 0.
High: Stop payload drops terminationReason Address Updated Stop payload translation to pass termination_reason, fully_idle, and error.
High: invoke_subagent deny evaluation Address Clarified & tested that if any subagent triggers deny, the whole tool call immediately denies.
Medium: Fragile mock_run.call_args_list indexing Address Updated test to safely access keyword arguments via c.kwargs['input'].
Medium: Multi-message PreInvocation join coverage Address Added explicit test verifying multi-message `

joining across multipleUserPromptSubmithooks. | | Low:ADAPTER_SCRIPTexistence guard | **Address** | Added explicit existence assertion in test setUp andload_adapter. | | Low: memories/antigravity.mdStop decision doc gap | **Address** | Clarified flat vs grouped schema and non-blocking return values inmemories/antigravity.md`. |

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review — PR #2233

plugins/ai-config/claude-hook-adapter.py


[CRITICAL] Stop event handler iterates a grouped structure that doesn't match the declared flat schema

memories/antigravity.md and the PR description state:

Stop handlers are flat (a direct list of { "type": "command", "command": "..." } objects without matcher/hooks wrappers)

But claude-hook-adapter.py processes Stop handlers as if they are grouped:

for group in stop_groups:
    for hook in group.get("hooks", []):   # <-- expects {"hooks": [...]} wrapper

And hooks.json registers Stop with the grouped schema too:

"Stop": [
  {
    "type": "command",
    "command": "python3 ./claude-hook-adapter.py"
  }
]

That entry has no "hooks" key — group.get("hooks", []) returns [] for every element, so Stop hooks are silently never executed. The inner hook bodies are never reached. This is the exact silent-failure mode the PR claims to prevent.

The same mismatch exists in MOCK_HOOKS_DEF in the test file, where Stop is given a grouped structure:

"Stop": [
  {
    "hooks": [ { "type": "command", ... } ]
  }
]

This is inconsistent with hooks.json. The test passes only because it mocks the grouped structure and the adapter code also expects the grouped structure — but the production hooks.json uses the flat structure. The test therefore does not cover the production configuration.


[CRITICAL] PreInvocation handler iterates a grouped structure that doesn't match the declared flat schema

Same issue. antigravity.md states:

PreInvocation handlers are also flat

hooks.json registers PreInvocation with a flat entry (no "hooks" wrapper), but the adapter's PreInvocation branch iterates ups_groups with:

for group in ups_groups:
    for hook in group.get("hooks", []):

For a flat entry like {"type": "command", "command": "..."}, group.get("hooks", []) is []. PreInvocation hooks are also silently never executed in production.


[HIGH] hooks.json self-reference creates infinite recursion

plugins/ai-config/hooks.json registers the adapter itself as the handler for Stop and PreInvocation:

"Stop": [
  { "type": "command", "command": "python3 ./claude-hook-adapter.py" }
],
"PreInvocation": [
  { "type": "command", "command": "python3 ./claude-hook-adapter.py" }
]

The adapter, when invoked for a Stop or PreInvocation event, opens hooks/hooks.json (the hooks-directory hooks.json, not the plugin one — path constructed via repo_root). However the adapter for PreToolUse was already the claude-hook-adapter.py, and the pattern of registering the adapter as its own hook handler for Stop/PreInvocation suggests the adapter reads hooks/hooks.json and may invoke hook commands that call back into the adapter. Whether this causes actual recursion depends on the hooks/hooks.json contents, but the architecture is fragile and the registration in plugins/ai-config/hooks.json is not tested at all — no test exercises the production plugins/ai-config/hooks.json file, only the synthetic MOCK_HOOKS_DEF.


[HIGH] matches_tool returns False for empty matcher string, breaking wildcard/catch-all use cases

def matches_tool(matcher_pattern, tool_name):
    if not matcher_pattern:
        return False

An empty matcher string returning False rather than True (match-all) is a semantic choice with no documentation. If any existing hook group intentionally uses an empty matcher as a wildcard, it is silently skipped. This is undocumented and untested.


[HIGH] MCP tool fanout never dispatches to non-mcp__github__ tools

Only mcp__github__-prefixed tools get dispatched via the explicit elif tool_name.startswith("mcp__github__") branch. Any other mcp__* tool (e.g. mcp__filesystem__*, mcp__jira__*) falls through all branches without dispatching to any hook group, even if a regex matcher in hooks.json would match it. The correct behavior would be a generic fallback that attempts regex matching for all unrecognized tool names.


[MEDIUM] test_pre_invocation_multi_message_join tests a grouping structure absent from the flat-spec MOCK_HOOKS_DEF

MOCK_HOOKS_DEF's UserPromptSubmit entry is:

"UserPromptSubmit": [
    {
        "hooks": [ hook1, hook2 ]   # grouped, no matcher key
    }
]

The adapter's PreInvocation path iterates group.get("hooks", []), so this test does exercise the adapter correctly — but only because the mock was written to match the (wrong-per-spec) grouped structure the adapter expects. If hooks/hooks.json uses flat entries (consistent with the stated spec for Stop and PreInvocation), both are broken.


[MEDIUM] No test for run_commandBash mapping or send_messageSendMessage mapping

The four tests cover PreInvocation, Stop, invoke_subagent fanout, and MCP regex. The primary run_commandBash translation — which was the only path in the original code — has no test in the new suite. This is a coverage gap given the stated CI gate goal.


[MEDIUM] timeout_val passed to hook.get("timeout") can silently be a string left as a string

timeout_val = float(hook["timeout"]) if hook.get("timeout") is not None else None

This raises ValueError if timeout is an invalid numeric string (e.g. "ten"). The old code wrapped this in try/except; the refactored code does not. An invalid timeout in hooks.json will crash the adapter and produce no output, which Antigravity may interpret as an allow (or an error depending on its behavior for adapter crashes). The old defensive pattern was removed without replacement.


[MEDIUM] result.stderr is not logged on non-zero exit in the refactored run_hook_command

The old code logged result.stderr separately on non-zero exit. The new run_hook_command only logs stderr if result.returncode != 0 and result.stderr — this is fine — but removes the additional stderr was: context line that aided debugging. Minor regression in observability.


[LOW] shell=True with cmd.replace("${CLAUDE_PLUGIN_ROOT}", repo_root) is a command injection vector

repo_root is derived from os.path.abspath(__file__), so in practice it is controlled. However shell=True with a string command that includes a user-influenced path (repo root could in principle contain shell metacharacters if the repo is cloned to an unusual path) is a latent injection surface. This was present before and not introduced by this PR, but the refactor did not take the opportunity to fix it.


[LOW] antigravity.md schema note inconsistency

antigravity.md states Stop/PreInvocation handlers are flat, but the test's MOCK_HOOKS_DEF uses a grouped wrapper for both. The documentation, the test fixture, and the production hooks.json are all three mutually inconsistent.


[LOW] validate.yml adds test_agy_hook_adapter.py but does not check its exit code explicitly

The multi-line run: block runs both scripts sequentially. If test_hooks.py exits non-zero, test_agy_hook_adapter.py is never reached (shell default set -e in GitHub Actions stops on first failure). This is acceptable behavior but means the new test is only reachable when the old test passes — no independent CI job or continue-on-error step exists for the new test.


Severity Breakdown

Severity Finding
🔴 Critical Stop hook processing iterates group.get("hooks", []) on a flat production structure — Stop hooks never execute
🔴 Critical PreInvocation hook processing same bug — PreInvocation hooks never execute
🟠 High Self-referential hooks.json registration for Stop/PreInvocation is untested and structurally fragile
🟠 High matches_tool("")False is undocumented and may silently skip catch-all matchers
🟠 High Non-mcp__github__ MCP tools fall through all branches with no hook dispatch
🟡 Medium No test for run_commandBash translation (primary pre-existing path)
🟡 Medium float(hook["timeout"]) on invalid string raises ValueError; old try/except was removed
🟡 Medium MOCK_HOOKS_DEF grouped Stop/PreInvocation structure contradicts flat spec and production hooks.json
🔵 Low shell=True + interpolated path — latent injection surface
🔵 Low antigravity.md documentation is internally inconsistent with test fixtures
🔵 Low test_agy_hook_adapter.py only runs if test_hooks.py passes first; no independent CI step

Verdict: Needs work
Reviewed-Commit: efa74cc

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 3

Finding Disposition Details / Evidence
Critical: Flat vs grouped schema handling Address Implemented extract_hook_list to transparently support both flat [{"command": ...}] and grouped [{"hooks": [...]}] lists across all event types.
High: Empty string / wildcard matcher Address Updated matches_tool to treat "", None, and "*" as catch-all matchers.
High: Generic / non-GitHub MCP tool handling Address Added generic tool fallback to evaluate all unmapped tools against registered matchers.
Medium: Pre-existing run_command -> Bash test coverage Address Added test_run_command_to_bash test in test_agy_hook_adapter.py.
Medium: Timeout string defensive parsing Address Added parse_timeout helper with explicit try/except around float() parsing.

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review — PR #2233


plugins/ai-config/claude-hook-adapter.py

[HIGH] Stop handler falls through to {"decision": "allow"} — wrong default for a Stop hook

After all Stop hooks run, the code emits {"decision": "allow"}. Antigravity's Stop hook expects either {"decision": "continue"} (to block termination) or allows termination otherwise. However, the semantics of a Stop hook differ from a PreToolUse hook — "allow" is not a documented valid Stop response. If the downstream runtime ignores unrecognised keys the behaviour is fine, but if it treats allow as an error or unexpected value, the agent will never be blocked from terminating even when the hook doesn't fire. The correct permissive fallthrough for Stop is {} (empty object, no decision) or an explicit documentation clarification. At minimum, this should use {"decision": "allow"} only if that's confirmed valid per the Antigravity spec; if not, it should be {} or omitted.

[HIGH] extract_hook_list ignores the matcher key on grouped Stop/PreInvocation entries

Stop hooks in plugins/ai-config/hooks.json are declared as flat items per the memory doc ("Stop": [{"type": "command", ...}]). But MOCK_HOOKS_DEF in the test uses a grouped format for Stop ({"hooks": [...]} without a matcher). The adapter calls extract_hook_list(stop_groups) which handles both cases. However, extract_hook_list is never applied with a matcher filter for Stop — it runs all Stop hooks unconditionally. This is arguably correct for Stop (no matcher concept), but the test fixture uses a Stop entry that has "hooks":[...] without a matcher — implicitly relying on the group-unwrapping path. If in production the manifest uses flat format (as specified in the memory), the grouped test fixture diverges from production reality, weakening coverage. The test only covers the grouped path; no test covers the flat Stop path that matches what hooks.json actually contains.

[MEDIUM] PreInvocation all messages collapsed into one ephemeralMessage

All injected text from multiple UserPromptSubmit hooks is joined into a single ephemeralMessage. If the underlying Antigravity injectSteps API supports multiple steps (one per hook), collapsing them loses per-hook attribution and may exceed a single message size limit. The memory doc says "joining multiple hook outputs with \n\n" — so this matches the documented design — but there's no upper-bound guard on the combined message size, which could produce an oversized ephemeral message in production.

[MEDIUM] invoke_subagent fanout constructs one Agent payload per subagent but groups hook tasks per matching group, potentially running hooks multiple times per group

In the invoke_subagent branch, for each subagent the code loops over all pre_tool_groups and appends (extract_hook_list([group]), agent_payload) to tasks_to_run if the group matches "Agent". With two subagents and one matching group, tasks_to_run gets two entries — each with the same hook list from that group. This means each hook in the matching group runs twice (once per subagent). The intent is correct (evaluate each subagent independently), but the implementation re-runs hooks from the same group N times where N = number of matching groups × number of subagents, rather than once per (hook, subagent) pair. With one group and two subagents it behaves correctly, but with two groups both matching "Agent", each hook in each group runs twice — once for each subagent — but also the second group's hooks run for both subagents before the first group's hooks finish per-subagent ordering. This can cause a hook from group 2 to see an allow where group 1's deny should have already fired and aborted. The deny-on-first logic handles this correctly sequentially, but the coupling of hooks to payloads is fragile.

[MEDIUM] cwd for non-run_command tools defaults to os.getcwd() at call time, ignoring working directory context

For invoke_subagent, send_message, define_subagent, and generic tools, cwd used to run the subprocess is os.getcwd() — the adapter process's working directory — rather than any meaningful workspace. Only run_command correctly extracts Cwd from args. This means hook commands for agent/send/task events execute from an arbitrary directory, which could cause relative path resolution issues inside those hook scripts.

[MEDIUM] run_hook_command uses shell=True with the raw command string — shell injection risk

cmd = cmd.replace("${CLAUDE_PLUGIN_ROOT}", repo_root) and then subprocess.run(cmd, shell=True, ...). If repo_root contains shell metacharacters (spaces, semicolons, backticks), this could cause unintended shell execution. On Windows this is additionally fragile. The repo root path should be shell-quoted, or the command templating should pass the path via environment variable rather than string interpolation.

[LOW] matches_tool returns True for matcher_pattern == "" (empty string)

An empty matcher string matching every tool seems unintentional. If a hook group has "matcher": "" due to a config mistake, it will silently fire on all tools. Should default to False for empty string, or be treated as a misconfiguration with a warning.

[LOW] run_hook_command logs the full cmd string on error, which may leak sensitive command arguments to stderr

The command string may contain API tokens or file paths embedded via ${CLAUDE_PLUGIN_ROOT} expansion. Logging the full resolved command to stderr may surface credentials in logs.

[LOW] traceback import removed without replacement

The original code imported traceback. It's been removed. If any exception handling previously used traceback.format_exc(), those call sites would now fail. A grep of the diff shows no surviving traceback references, but the removal should be confirmed against the full (not diff-only) file to ensure no remaining callers.


scripts/test_agy_hook_adapter.py

[MEDIUM] test_pre_invocation_multi_message_join asserts on raw \n in stripped output

The test expects "Message 1\n\nMessage 2""Message 1\n" stripped is "Message 1", then joined with "\n\n" gives "Message 1\n\nMessage 2". That passes. However "Message 2\n" stripped is "Message 2", so the expected string is correct. But the assertion is fragile to trailing whitespace in hook output; a hook returning "Message 2\n\n" would strip to "Message 2" and still pass. This is a test robustness note rather than a logic error, but documents a coverage gap: no test verifies behaviour when a hook returns an empty string after stripping (i.e., that empty outputs are excluded from injected_messages).

[MEDIUM] No test for unknown/unmapped Antigravity event payload (e.g., {} or {"someNewField": ...})

The adapter returns {"decision": "allow"} for unrecognised payloads. This is the silent-failure mode the memory doc explicitly warns about. There is no test asserting this behaviour, meaning a future payload format change would silently allow rather than surface an error.

[MEDIUM] No test for the flat Stop format that hooks.json actually uses

As noted above, the production hooks.json Stop entry is flat ({"type": "command", "command": "..."}) but MOCK_HOOKS_DEF uses the grouped path. No test covers the flat Stop path via extract_hook_list.

[LOW] @patch('os.path.exists', return_value=True) patches globally — may mask unintended file-existence checks

Patching os.path.exists globally to always return True could suppress legitimate "file not found" branches in the adapter (e.g., if a hook command path doesn't exist). Tests should be more surgical or verify the hooks-json-not-found fallback with a separate test.

[LOW] test_wildcard_and_mcp_matching comment says "Matches both mcp__github__. and " but does not assert which hooks matched

The test only checks call_count == 2 and the first call's tool_name. It doesn't verify that one invocation corresponds to the mcp__github__.* group and the other to the * group. A matcher regression could still pass the count check if two hooks from the same group were triggered.


plugins/ai-config/hooks.json

[MEDIUM] Stop and PreInvocation entries call python3 ./claude-hook-adapter.py — recursive self-invocation

The adapter is registered as a Stop and PreInvocation handler in its own plugin's hooks.json. If Antigravity loads this plugin's hooks.json AND the adapter itself tries to load hooks/hooks.json (the Claude hooks manifest), this is not self-recursive in the code path — but if Antigravity ever routes Stop through the plugin's own hooks.json entry (python3 ./claude-hook-adapter.py) and the adapter then tries to execute Stop hooks from the Claude manifest, the Stop hooks in the Claude manifest could themselves call back into this same adapter, creating potential for a loop. This depends entirely on the Antigravity runtime not re-triggering plugin hooks recursively, which is not verified by any test.

[LOW] matcher regex run_command|invoke_subagent|send_message|define_subagent|mcp__github__.* uses alternation containing a regex wildcard

mcp__github__.* contains regex syntax inside a pipe-alternated pattern. If Antigravity evaluates this as a plain string match (not regex), the .* will not expand. If it evaluates as regex, the run_command|invoke_subagent|... literals still match correctly but there is no escaping. The adapter's matches_tool handles this via re.fullmatch, but if the Antigravity runtime itself also evaluates the matcher (for routing to this adapter), a mismatch between Antigravity's matcher semantics and the adapter's regex semantics could cause the adapter to never be invoked for mcp__* calls.


.github/workflows/validate.yml

[LOW] test_agy_hook_adapter.py added to CI but with no explicit Python version constraint

test_hooks.py and test_agy_hook_adapter.py run with python3 but the workflow does not pin a Python version for this step. If the runner's default python3 changes, adapter tests could behave differently. Minor but worth pinning.


memories/MEMORY.md / memories/antigravity.md

No logic or security issues. Content accurately reflects the adapter architecture. The note about {"decision": "allow"} being passed when the adapter misses an event is the clearest documentation of the silent-failure risk — good to have captured.


Findings Summary

Severity Count Issues
High 2 Stop fallthrough emits allow (spec validity unclear); extract_hook_list test fixture diverges from production flat-format Stop
Medium 6 Single oversized ephemeralMessage; invoke_subagent hook-group × subagent cross-product ordering; non-run_command cwd defaults to adapter cwd; shell=True injection via repo_root; no test for unknown payloads; no test for flat Stop path
Low 6 Empty matcher matches all; cmd logged on error (credential leak); traceback removal unverified; os.path.exists over-patched; wildcard test doesn't assert which groups fired; Python version unpinned in CI

Verdict: Needs work

Reviewed-Commit: cad61ff

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 4

Finding Disposition Details / Evidence
High: Flat format Stop/PreInvocation test fixture Address Added test_flat_schema_tolerance in test_agy_hook_adapter.py asserting flat [{"type": "command", ...}] definitions execute cleanly.
Medium: Multi-message injectSteps structure Address Updated adapter to emit separate {"ephemeralMessage": msg} entries for each injected message in injectSteps.
Medium: cwd fallback for non-run_command tools Address Set cwd fallback to repo_root rather than adapter process os.getcwd().
Medium: Unknown/unmapped Antigravity event payload Address Added test_unknown_event_payload asserting {"decision": "allow"} is returned for unknown events.
Low: Matcher empty string handling Address Clarified matches_tool to require non-empty matcher string (if not matcher_pattern: return False).

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review — PR #2233


plugins/ai-config/claude-hook-adapter.py

1. invoke_subagent fanout uses wrong cwd for hook execution

Severity: High (Logic Error)

For invoke_subagent, cwd is read as args.get("Cwd") or repo_root at the top of the PreToolUse block — but invoke_subagent payloads don't carry a Cwd field; only run_command does. The adapter correctly falls back to repo_root, so this is not broken, but the assignment happens unconditionally before the if tool_name == "run_command" branch. This means for invoke_subagent, send_message, and define_subagent, cwd will always be repo_root (because args.get("Cwd") returns None). That's the correct intent, but the code is confusingly structured: cwd = args.get("Cwd") or repo_root runs for every tool before we know which tool it is. This is a latent readability/maintenance hazard that will silently do the wrong thing if a future tool type has a different cwd semantics.

2. Multi-subagent fanout: hook list rebuilt per-subagent per-group, causing duplicate runs

Severity: Medium (Logic Error)

In the invoke_subagent branch:

for sub in subagents:
    agent_payload = { ... }
    for group in pre_tool_groups:
        if matches_tool(group.get("matcher", ""), "Agent"):
            tasks_to_run.append((extract_hook_list([group]), agent_payload))

extract_hook_list([group]) wraps group in a single-element list and extracts from it. This is correct per-group, but if there are N subagents and M matching groups each with K hooks, you get N×M entries in tasks_to_run, each running K hooks. That is intentional for the per-subagent fanout, but the outer loop appends a new (hooks_list, payload) tuple for every matching group for every subagent, meaning a single Agent group with 2 hooks and 3 subagents produces 3 entries in tasks_to_run each holding 2 hooks (correct). However, if there are 2 matching Agent groups, you get 6 entries, running 12 hook invocations for 3 subagents with 2 hooks each — potentially correct depending on intent, but the test test_multi_subagent_fanout_and_deny only covers the single-group case and would not catch double-firing.

3. Stop hook extract_hook_list call: grouped Stop hooks in MOCK_HOOKS_DEF won't be found

Severity: Medium (Logic + Test Gap)

MOCK_HOOKS_DEF defines Stop as a grouped format:

"Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }]

extract_hook_list handles this correctly (it recurses into item["hooks"]). However, the CI test for Stop (test_flat_schema_tolerance) only tests the flat MOCK_FLAT_HOOKS_DEF path. There is no test that exercises the grouped Stop schema from MOCK_HOOKS_DEF. The MOCK_HOOKS_DEF Stop entry is defined but never tested, leaving the grouped Stop code path untested — directly violating the PR's stated goal (MEMORY.md: "gated by isolated unit tests").

4. matches_tool returns False for empty matcher — silently drops hooks

Severity: Medium (Logic Error)

def matches_tool(matcher_pattern, tool_name):
    if not matcher_pattern:
        return False

If a group has "matcher": "" (empty string), hooks are silently skipped. The old code used group.get("matcher") == "Bash" which is consistent. But the new generic fallback path calls matches_tool(group.get("matcher", ""), tool_name) — an absent matcher key yields "" which returns False. This means a group with no matcher field is silently ignored rather than raising an error or matching all tools. This is inconsistent with common hook config semantics where an absent matcher typically means "match all." The MEMORY.md document says Stop and PreInvocation handlers are flat (no matcher), but PreToolUse handlers always have a matcher. Still, this is a footgun: misconfigured hooks fail silently.

5. Stop default output when no hook blocks: returns {"decision": "allow"} — semantically wrong for Antigravity

Severity: Medium (Logic Error / Integration Gap)

After processing all Stop hooks, if none return "block", the adapter outputs:

print(json.dumps({"decision": "allow"}))

But per MEMORY.md: "To prevent termination … Antigravity expects {"decision": "continue", "reason": "..."}. Any other value (or {"decision": "allow"}) allows termination."

So {"decision": "allow"} is technically safe (it allows termination, as intended when no hook blocks). However, the Antigravity Stop contract may not recognize "allow" at all — only "continue" is documented as a valid stop-prevention signal. Emitting "allow" from a Stop handler, rather than omitting output or returning {}, may cause unexpected behavior depending on how Antigravity parses the response. The safe/idiomatic response for "allow termination" in a Stop handler should be {} or no output, not {"decision": "allow"} which is a PreToolUse vocabulary term.

6. PreInvocation output uses UserPromptSubmit hooks — key mismatch risk

Severity: Low-Medium (Integration Gap)

The adapter maps PreInvocationUserPromptSubmit hooks in hooks.json:

ups_groups = hooks_def.get("hooks", {}).get("UserPromptSubmit", [])

This mapping is documented in MEMORY.md and is intentional. However, hooks.json (hooks/hooks.json, the Claude hooks file) uses the Claude event name UserPromptSubmit, while the Antigravity event is PreInvocation. If the Claude hooks.json ever adds a native PreInvocation key, the adapter will silently pick up the wrong hooks. More critically: there is no test that asserts UserPromptSubmit groups are correctly extracted when they have a matcher field (Claude's UserPromptSubmit hooks don't use matchers, but extract_hook_list is called on them through the same path that handles grouped objects). In MOCK_HOOKS_DEF, the UserPromptSubmit entry uses the grouped schema without a matcher, which extract_hook_list handles via the "hooks" in item branch — but this is untested with a matcher present.

7. hooks.json Stop/PreInvocation command is python3 ./claude-hook-adapter.py — self-referential infinite recursion risk

Severity: High (Logic Error / Integration Gap)

In plugins/ai-config/hooks.json:

"Stop": [{ "type": "command", "command": "python3 ./claude-hook-adapter.py" }],
"PreInvocation": [{ "type": "command", "command": "python3 ./claude-hook-adapter.py" }]

The adapter is claude-hook-adapter.py. These entries cause the Antigravity runtime to call the adapter for Stop and PreInvocation. The adapter then reads hooks/hooks.json (Claude's hook file) to find Stop/UserPromptSubmit hooks. The Antigravity hooks.json (plugins/ai-config/hooks.json) and the Claude hooks/hooks.json are different files, so this is not directly recursive. However, the adapter's repo_root calculation is:

repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

This goes up 3 levels from plugins/ai-config/claude-hook-adapter.py. If the adapter is invoked from a different working directory (which is possible since the hook command is python3 ./claude-hook-adapter.py — relative path), __file__ could resolve correctly, but only if Python resolves the script's own path rather than the CWD. This is reliable in CPython but worth flagging for portability.

Additionally, the Stop hook in plugins/ai-config/hooks.json doesn't pass any payload on stdin — the adapter reads from sys.stdin, so it depends on the Antigravity runtime piping the Stop payload. If Antigravity doesn't pipe stdin for Stop events, the adapter will read an empty stdin and print {"decision": "allow"} silently.

8. run_hook_command swallows TimeoutExpired and returns None — timeout is always allowed through

Severity: Low (Logic Error)

When a hook times out, run_hook_command returns None. The caller checks if result and result.returncode == 0 and result.stdout: — so a timeout silently falls through to {"decision": "allow"}. For security-sensitive hooks (e.g. the merge-control hook), a timeout should arguably be treated as a deny or at least logged more visibly. This matches the old behavior but is worth flagging as a security policy gap.

9. validate.yml — no exit-code isolation between test scripts

Severity: Low (Harness Gap)

run: |
  python3 scripts/test_hooks.py
  python3 scripts/test_agy_hook_adapter.py

Both scripts run in a single shell step. If test_hooks.py fails, test_agy_hook_adapter.py still runs (bash default). Conversely, if test_agy_hook_adapter.py fails, the step is marked failed, which is correct. However, test_hooks.py failure output will be mixed with test_agy_hook_adapter.py output in the CI log, making triage harder. They should be separate steps or use set -e. In bash, the | multiline run uses set -e by default in GitHub Actions shell steps, so a failure in test_hooks.py will prevent test_agy_hook_adapter.py from running — meaning the new test can be masked by pre-existing failures in test_hooks.py. They should be separate named steps.

10. test_agy_hook_adapter.pymock_open does not support encoding kwarg correctly in all Python versions

Severity: Low (Test Reliability)

The adapter opens hooks.json with open(..., encoding="utf-8"). mock_open in Python < 3.7.1 does not handle the encoding keyword argument transparently. While this is unlikely to be an issue in practice given Python 3.8+ CI environments, it is worth noting that the mock may not cover the encoding path faithfully.

11. memories/antigravity.md — documents blockcontinue translation but adapter only catches "block" exactly

Severity: Low (Documentation / Edge Case)

The adapter checks:

if hook_out.get("decision") == "block":

If a Claude Stop hook outputs {"decision": "BLOCK"} (uppercase) or any other casing, the translation is silently skipped and termination is allowed. The MEMORY.md document does not clarify case sensitivity of the decision field. Given that Claude Code hook outputs are machine-generated, this is low risk but worth a .lower() normalization.


Findings by Severity

Severity # Issues
High 2 Self-referential command path + stdin dependency for Stop/PreInvocation (#7); invoke_subagent cwd assignment before tool-type branching (#1)
Medium 4 Multi-subagent double-firing with multiple Agent groups (#2); grouped Stop schema untested (#3); empty-matcher silent skip (#4); {"decision":"allow"} wrong vocabulary for Stop handler (#5)
Low 5 UserPromptSubmit key mismatch risk (#6); timeout silent allow-through (#8); CI step isolation (#9); mock_open encoding (#10); case-sensitive "block" check (#11)

Verdict: Needs work

Reviewed-Commit: 9e6c340

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 5

Finding Disposition Details / Evidence
Medium: Stop allowed output format Address Updated Stop handler to return clean empty object {} when no hook blocks termination.
Low: Case-insensitive "block" match Address Added case-insensitive .lower() normalization when checking Claude Stop hook decision output.
Low: CI step isolation Address Separated Run Antigravity hook adapter tests into a dedicated named step in .github/workflows/validate.yml.
Test Coverage: Full test suite expansion Address Retained and verified all 7 unit test cases including multi-subagent fanout, regex matching, Stop allow {} return, and flat/grouped schema tolerance.

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

It looks like your message came through empty. What can I help you with?

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial Review — PR #2233 (claude-hook-adapter refactor + AGY adapter)


1. Logic Errors

[L1] Stop event "block" semantics are inverted
In the Stop event handler, the adapter translates a hook returning {"decision": "block"} into {"decision": "continue", "reason": ...}. This is backwards and contradictory. If a hook signals "block" (i.e., "don't stop yet"), the adapter should output something that causes the agent to not stop — typically {"decision": "block"} or a specific AGY equivalent. Outputting "continue" when the intent is to prevent stopping is a logic error. The test test_flat_schema_tolerance asserts this broken behavior, meaning the test itself is validating the wrong outcome. The semantics need to be verified against the actual AGY Stop event protocol.

[L2] define_subagent"Task" mapping is wrong
define_subagent is a tool that defines a new subagent type (registers a type definition). It is not the same as task invocation/dispatch. Mapping it to "Task" in the Claude Code payload conflates definition with execution. If any Claude hook logic branches on tool_name == "Task", it will be silently misapplied. invoke_subagent is the actual task-dispatch equivalent.

[L3] Multi-subagent fanout: all subagents share the same hook list per group
In the invoke_subagent branch, each subagent in Subagents is fanned out to all groups matching "Agent". However, because tasks_to_run accumulates (hooks_list, agent_payload) pairs and the outer loop iterates all of them together, the hooks for subagent N will run even if subagent N-1 was already denied. The early-return on deny does short-circuit correctly within the execution loop, but the construction of tasks_to_run interleaves payloads in a way that means hooks for subagent 2's payload run after the allow/deny for subagent 1 is resolved — only by accident of list ordering. This is correct today but is fragile; a matcher that matches multiple groups would cause cross-subagent hook combinations. More critically, the deny for subagent 2 in the test (test_multi_subagent_fanout_and_deny) relies on the specific order mock_run is called, which means the test is order-sensitive and brittle.

[L4] PreInvocation maps to UserPromptSubmit — ambiguous and undocumented
The adapter fires UserPromptSubmit Claude Code hooks for AGY's PreInvocation event. These are semantically distinct: UserPromptSubmit is tied to a user submitting a new turn, while PreInvocation in AGY fires before each agent invocation (which can be model-triggered mid-turn). This mapping will cause UserPromptSubmit hooks to run many more times than their authors expect, potentially causing side effects (logging, rate-limiting, auditing hooks) to fire excessively. The mapping assumption must be documented in hooks.json and AGENTS.md.

[L5] event_type detection priority: invocationNum may collide
The payload is tested for "invocationNum" to detect PreInvocation. But "invocationNum" could conceivably appear in a PreToolUse or other payload if the AGY runtime passes it as context. The detection logic uses a first-match cascade without checking for mutual exclusivity. The correct approach is to detect on a discriminant field guaranteed unique to each event type (e.g., a "hookEventName" field if one exists).


2. Security Issues

[S1] shell=True with unsanitized command strings from hooks.json
run_hook_command passes cmd to subprocess.run(..., shell=True). The cmd is loaded from hooks.json after a simple ${CLAUDE_PLUGIN_ROOT} substitution using str.replace. If repo_root contains shell metacharacters (spaces, semicolons, backticks), or if hooks.json is writable by an untrusted party, this enables command injection. The substitution should use shlex.quote(repo_root) when embedding into a shell string, or the command should be tokenized and run without shell=True.

[S2] env=os.environ propagates all environment variables to hook subprocesses
Sensitive environment variables (tokens, credentials, API keys) are passed wholesale to every hook subprocess. Hooks should receive a minimal, scoped environment. At minimum, a comment acknowledging this is needed; ideally, a filtered env dict is constructed.

[S3] Hook command input (claude_payload) includes transcript_path
transcript_path comes directly from the AGY payload and is embedded in the JSON sent to hook subprocesses. A malicious or misconfigured hook could use this path to read or overwrite the agent's transcript. The field should be validated (path within a known safe directory) before forwarding.

[S4] hooks.json path traversal via repo_root
repo_root is computed as os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))). This is three levels up from the adapter file. If the adapter is moved or symlinked, repo_root resolves differently, potentially loading an attacker-controlled hooks.json from a different directory. No integrity check (hash, signature) on hooks.json is performed.


3. Missing Edge Cases

[E1] run_hook_command returns None on timeout or exception — callers don't distinguish timeout from allow
When run_hook_command returns None, all callers treat it as "no deny" and continue. A timed-out security hook silently passes. This should be a configurable fail-open vs. fail-closed policy, with fail-closed (deny) as the secure default for PreToolUse.

[E2] matches_tool returns False for empty matcher_pattern
A hook group with matcher: "" (empty string) is silently skipped. If an operator intends an empty matcher to mean "match all" (as some hook systems do), this fails silently. The behavior should be documented or the empty-string case should raise a warning.

[E3] parse_timeout(None) returns None, passed to subprocess.run(timeout=None)
subprocess.run with timeout=None means no timeout. A hook with no timeout field runs indefinitely. This is a denial-of-service vector if a hook hangs. A global maximum timeout should be enforced.

[E4] extract_hook_list silently drops items that have neither "hooks" nor "command" keys
Malformed hook entries (e.g., {"type": "command"} without a "command" key, or {"hooks": "not-a-list"}) are dropped without warning. At minimum a stderr log is warranted.

[E5] PreInvocation with invocationNum: 0 — falsy check risk
The code does payload.get("invocationNum") and uses truthiness elsewhere. invocationNum: 0 would be a falsy value. While the detection uses "invocationNum" in payload (correct), if any downstream code ever switches to payload.get("invocationNum"), it will mishandle invocation 0.

[E6] Stop event: no cwd is set — uses repo_root
The Stop handler passes repo_root as the cwd for hook execution. This is reasonable but undocumented. If a stop hook needs to run relative to the project being worked on, repo_root is wrong.


4. Harness Integration Gaps

[H1] test_agy_hook_adapter.py is not registered in test_hooks.py's catalog check
The CI step added (Run Antigravity hook adapter tests) runs this file, but the existing test_hooks.py step also checks that every hook has a test. It's not shown that test_agy_hook_adapter.py is registered with that catalog, meaning the ai-config#1080 guard ("an untested hook cannot hide") may not apply to the new Stop and PreInvocation entries added to hooks.json.

[H2] hooks.json (in plugins/ai-config/) vs. hooks/hooks.json (at repo root) — two files, one adapter
The adapter reads from hooks/hooks.json (repo-root-relative), but the diff also modifies plugins/ai-config/hooks.json. These are two different files. The adapter never reads plugins/ai-config/hooks.json. The new Stop and PreInvocation entries added to plugins/ai-config/hooks.json will never be loaded by the running adapter. This is a critical harness gap: the tests mock the hooks definition inline and never validate that the adapter reads the actual plugins/ai-config/hooks.json.

[H3] Test uses mock_open which does not support multiple open() calls correctly
mock_open with a single read_data value serves the same data for every open() call. If the adapter or any downstream code opens additional files (e.g., a second JSON file for configuration), the mock silently serves the wrong data. The tests should verify open is called with the expected path.

[H4] test_wildcard_and_mcp_matching asserts call_count == 2 but is fragile
The expected count of 2 (one for mcp__github__.* matcher, one for * wildcard) depends on the exact order and content of MOCK_HOOKS_DEF. If a new matcher is added to the mock, this test breaks without warning. Use assertGreaterEqual or verify both hooks' payloads explicitly.

[H5] No test for run_command hook denial path
There is a test for invoke_subagent deny, but no test verifies that a run_command mapped to Bash can be denied by a hook returning permissionDecision: "deny". This is the most critical path (blocking shell commands) and has no direct test coverage.

[H6] No test for hooks.json missing or malformed
The adapter has three distinct error paths for missing/malformed hooks.json (per event type). None of these are exercised in the test suite.

[H7] @patch('os.path.exists', return_value=True) patches globally
This patches os.path.exists for all calls, including any internal Python imports or stdlib usage that depends on os.path.exists. A more targeted patch (e.g., @patch('claude_hook_adapter.os.path.exists')) would be safer, but since the adapter is loaded as a module, the patch target should be confirmed.


5. AGENTS.md Violations

[A1] No AGENTS.md section documents the new event-type mapping
The PR description notes that memories/antigravity.md was created, but the diff does not show any update to AGENTS.md (or equivalent top-level contributor docs) describing the new PreInvocation/Stop/PreToolUse → AGY event mapping. Contributors reading AGENTS.md would not know this adapter exists, what it does, or that hooks.json is only partially used.

[A2] plugins/ai-config/hooks.json Stop/PreInvocation entries have no matcher field
The existing PreToolUse entry uses a matcher field. The new Stop and PreInvocation entries are added at the wrong level of the JSON structure — they are placed directly under "enforce-merge-control" as siblings to "PreToolUse", but the inner items ({"type": "command", "command": "..."}) lack the "hooks" wrapper that the adapter's extract_hook_list expects for grouped format. extract_hook_list will treat these as flat items (via the "command" in item branch), which works, but it's inconsistent with the grouped format used by PreToolUse and may confuse maintainers.


Severity Breakdown

ID Severity Description
S1 Critical shell=True + unsanitized repo_root substitution → command injection
H2 Critical Adapter reads wrong hooks.json; new Stop/PreInvocation entries are dead config
L1 High Stop "block" → "continue" translation is inverted; test validates wrong behavior
L2 High define_subagent"Task" mapping is semantically incorrect
E1 High Hook timeout silently treated as allow; security hooks can be bypassed by hanging
L4 High PreInvocationUserPromptSubmit fires too broadly; undocumented assumption
S2 Medium Full os.environ forwarded to hook subprocesses
S3 Medium transcript_path forwarded to hooks without path validation
H5 Medium No test for Bash hook denial path (most critical security path)
H1 Medium New hooks not registered with test_hooks.py catalog guard
L3 Medium Multi-subagent fanout hook execution is order-sensitive and brittle
L5 Low invocationNum event detection not guaranteed to be mutually exclusive
E2 Low Empty matcher_pattern silently skips hooks
E3 Low No global maximum timeout enforcement
E4 Low Malformed hook entries dropped without warning
H3 Low mock_open does not validate which file is opened
H4 Low Wildcard call-count assertion is fragile
H6 Low No test for missing/malformed hooks.json error paths
A1 Low AGENTS.md not updated with adapter architecture
A2 Low hooks.json Stop/PreInvocation entries use inconsistent schema format
S4 Low No integrity check on hooks.json

Verdict: Needs work
Reviewed-Commit: 564f008

Posted by Antigravity (AI agent) --- not written by a human.

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

ARD Response — Round 6

Finding Disposition Details / Evidence
Critical (S1): shell=True execution Rebut Hook commands defined across hooks/hooks.json rely on standard POSIX shell semantics (e.g. inline environment variable prefixes AI_CONFIG_STOP=1 python3 ..., pipe redirection, shell expansion). shell=True is the intentional and necessary execution mode for lifecycle hook scripts across all supported platforms.
Critical (H2): Dual hooks.json architecture Rebut plugins/ai-config/hooks.json is registered with Antigravity to route lifecycle events to claude-hook-adapter.py. The adapter then reads hooks/hooks.json (the Claude Code catalog) to execute the actual shared Python/Bash guard scripts. There is no dead config.
High (L1): Stop block-to-continue translation Rebut In Antigravity, {"decision": "continue"} is the exact contract to prevent the agent from stopping and re-enter the execution loop (agy-customizations/docs/hooks.md). Claude Code Stop hooks emit {"decision": "block"} to achieve the same result. The translation is exact.
High (H5): Missing Bash hook denial test Address Added test_run_command_deny in scripts/test_agy_hook_adapter.py.
High (E3): Global maximum timeout guard Address Added or 30.0 fallback in claude-hook-adapter.py when no timeout is declared on a hook entry.
Medium (H6): Missing hooks.json error path test Address Added test_missing_hooks_json_fallback in scripts/test_agy_hook_adapter.py.
Low (A1): AGENTS.md documentation Address Added hook integration subsection under Antigravity Workspace Rules in AGENTS.md referencing memories/antigravity.md.

Posted by Antigravity (AI agent) --- not written by a human.

@github-actions

This comment has been minimized.

claude added 2 commits August 26, 2026 23:24
Split the extended test from the prior commit into two separate test
methods, test_script_key_executes_in_stop and
test_script_key_executes_in_pre_invocation, so each test's name
exactly matches its own coverage instead of one test asserting on
both dispatch paths.

Re-ran the negative control (adapter line 361 hook.get("command") or
hook.get("script") -> hook.get("command")) against the split tests:
test_script_key_executes_in_pre_invocation fails (mock_run.call_count
0 != 1) as expected, with test_script_key_executes_in_stop still
passing since Stop is unaffected. Restored the line afterward.
Pulls in scripts/vendor/gha-check-new-line-breaks.py and other main
commits needed to run the validation gates for this fix round.

# Conflicts:
#	.github/workflows/validate.yml

Copy link
Copy Markdown
Collaborator

Pushed 314bf303..b4ca8476 (four fix commits plus a merge of current main, resolving one .github/workflows/validate.yml conflict by keeping both branches' independently-added CI steps), addressing findings 2-4 from the adversarial review at e5a27827 (comment 5432236835). Finding 1 (cursor[bot]'s standing verdict never re-cleared by cursor itself) is a process item: all of that round's findings are independently verified fixed in code, and the reconciliation is being surfaced to the repository owner rather than self-certified.

ARD table

# Finding Disposition Evidence
2 Unreachable else [] branch in the invoke_subagent guard Addressed (314bf303) Simplified to raw_subagents or []; deny guard unchanged; all tests pass
3 test_script_key_executes_in_stop_and_pre_invocation only exercised Stop Addressed (7ea81533, fffaf9d7) Split into distinct Stop and PreInvocation tests; negative control run twice — breaking the adapter's PreInvocation script-key line made the new test fail (AssertionError: 0 != 1) while the Stop test passed, then restored to 39/39
4 Unhedged live-UI claim in memories/antigravity.md ("surfaces it as a warning in the interface") Addressed (5f413197) Hedged in the file's existing style (secondary-source synthesis, checked 2026-08-26, unconfirmed against a live install); zero semicolons kept

Validation at b4ca8476: adapter suite 39/39, check-links.py clean (2466 links / 568 files), vendored new-line-breaks gate clean against origin/main, git merge-tree against current main clean. The push triggers the next review round.

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

claude added 3 commits August 27, 2026 00:42
- Regression: hook subprocess cwd fell back to repo_root (ai-config's own
  checkout) instead of the caller's real working directory, at every
  PreToolUse/Stop/PreInvocation dispatch site. A guard hook such as
  hooks/no-clobbering-push.py inherits this cwd, so it would have
  evaluated ai-config's own git state instead of the user's project.
  Restored the pre-PR `args.get("Cwd") or os.getcwd()` fallback and
  reused it (or plain os.getcwd()) everywhere repo_root was previously
  passed as the subprocess cwd. repo_root is kept for its legitimate use:
  locating hooks/hooks.json and rewriting ${CLAUDE_PLUGIN_ROOT}.

- invoke_subagent's Subagents lookup was single-case and fail-closed,
  unlike every sibling arg lookup in this file. Added a dual-case lookup
  with an explicit None check (not `or`), since an empty list is a real
  answer and must not be treated as a missing argument.

- matches_tool() silently returned False on an invalid regex matcher;
  it now logs a stderr diagnostic naming the bad pattern first, matching
  the file's other exception handlers.

- matches_tool() returned False for an omitted/empty matcher; Claude
  Code's documented PreToolUse semantics treat an absent matcher as
  match-all, so it now returns True (same as an explicit "*"). Checked
  both hooks/hooks.json and plugins/ai-config/hooks.json: no group
  currently omits its matcher, so this changes no live dispatch today.

- Stop and PreInvocation only read a top-level additionalContext field;
  Claude Code's documented hook-output shape nests it under
  hookSpecificOutput, as the PreToolUse branch already reads it. Both
  now also read the nested form, preferring whichever is present.

- Hoisted four bare-literal caps (invoke_subagent fanout limit, the
  PreInvocation per-message/total byte caps, and the injected-message
  count cap) to module-level constants read from AGY_ADAPTER_FANOUT_CAP,
  AGY_ADAPTER_MSG_BYTE_CAP, AGY_ADAPTER_TOTAL_BYTE_CAP, and
  AGY_ADAPTER_MSG_CAP, with a safe int-parse fallback (stderr diagnostic,
  never a crash) on a malformed override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rols

Adds/updates tests for each fix in claude-hook-adapter.py:

- Renamed and strengthened the cwd-fallback test to assert the actual cwd
  value (mocking os.getcwd() to a sentinel) rather than only checking it
  is absolute; added matching cwd tests for the generic-tool, Stop, and
  PreInvocation dispatch paths that previously hardcoded repo_root.
- invoke_subagent: a lowercase-only "subagents" payload now dispatches;
  an explicit empty "Subagents" list is allowed rather than denied as
  missing.
- matches_tool(): an invalid regex now logs a diagnostic naming the bad
  pattern; an empty/absent matcher now matches every tool.
- Stop and PreInvocation now surface a nested
  hookSpecificOutput.additionalContext, not just a top-level one.
- The invoke_subagent fanout cap is overridable via
  AGY_ADAPTER_FANOUT_CAP, and a malformed env value falls back to the
  default with a stderr diagnostic rather than crashing.

Each new/changed behavior was confirmed with a negative control: reverting
the corresponding fix locally and re-running the new test to observe it
fail, then restoring the fix and confirming it passes again (see
/tmp/claude-0/.../scratchpad/fix2233-summary.md for the transcripts).

Full suite: 51 tests, all passing.
python3 scripts/check-links.py also passes (no broken relative links).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Adversarial pre-push review — dispatched adversarial-reviewer subagent (Claude, Sonnet), briefed with the diff b4ca8476..fa34dd9f and the repo standards only. Report published verbatim below.


Summary of Changes

Two-commit delta on top of b4ca8476 addressing a six-finding review round on plugins/ai-config/claude-hook-adapter.py:

  1. Hook-subprocess cwd fallback changed from repo_root (ai-config's own checkout) to args.get("Cwd") or os.getcwd() for PreToolUse dispatch, and to os.getcwd() for Stop/PreInvocation dispatch, while repo_root is retained for locating hooks/hooks.json and rewriting ${CLAUDE_PLUGIN_ROOT}.
  2. invoke_subagent's Subagents lookup is now dual-case (Subagents/subagents) via an explicit is None check, so a present-but-empty list is not treated as a missing argument.
  3. matches_tool() now logs a stderr diagnostic naming the bad pattern before returning False on re.error.
  4. matches_tool() now returns True (match-all) for an empty/absent matcher instead of False.
  5. Stop and PreInvocation output parsing now also reads hookSpecificOutput.additionalContext (nested), not only a top-level additionalContext/systemMessage.
  6. Four previously hardcoded caps (fanout=50, per-message byte cap=10000, total byte cap=30000, message count cap=20) are now read from AGY_ADAPTER_FANOUT_CAP / AGY_ADAPTER_MSG_BYTE_CAP / AGY_ADAPTER_TOTAL_BYTE_CAP / AGY_ADAPTER_MSG_CAP via a new _int_env() helper that falls back to the default (with a stderr diagnostic) on a malformed value.

A companion commit adds/strengthens tests for all six items, including two new cwd-value assertions (generic-tool and Stop/PreInvocation paths) beyond the round's literal PreToolUse-only wording.

Findings

  1. [Minor] plugins/ai-config/claude-hook-adapter.py:160-167 (and the analogous os.getcwd() calls at lines 337, 416) — the fix's comment states the subprocess cwd "must be the caller's real working directory (falling back to this process's own cwd)," treating os.getcwd() as a reliable proxy. This repo's own memories/antigravity.md:12 (checked the same day as this PR) already documents "a known bug where that cwd can default to $HOME regardless of which project is open" for exactly the value Antigravity uses to launch the adapter subprocess — i.e. the same value os.getcwd() reads inside the adapter. The fix is still a net improvement over the unconditionally-wrong repo_root it replaces, but the comment overclaims a guarantee this codebase's own documentation says does not hold, and the PreInvocation payload's documented workspacePaths field (per the same memory file) was not investigated as a more reliable source. Recommend hedging the comment or noting the fallback is best-effort given the documented Antigravity cwd bug, rather than stating it "must be" the caller's directory.

  2. [Minor] memories/antigravity.md:57 (not touched by this diff) now reads stale against the fixed code: it describes the PreInvocation injection caps as fixed values ("capped at 10KB per message, 30KB and 20 messages total") with no mention that they are now configurable via AGY_ADAPTER_MSG_BYTE_CAP/AGY_ADAPTER_TOTAL_BYTE_CAP/AGY_ADAPTER_MSG_CAP, and doesn't mention the new nested hookSpecificOutput.additionalContext read added for Stop/PreInvocation. Worth a follow-up doc sync per "documentation... still in sync with the implementation."

No blocking findings. Every fix was independently verified:

  • Fact-checked against the live Claude Code hooks documentation (code.claude.com/docs/en/hooks): confirmed "*", "", or omitted matcher all mean "match all" (finding 4), and confirmed additionalContext is nested under hookSpecificOutput for both UserPromptSubmit and Stop events (finding 5) — both claims in the diff's comments are accurate.
  • Ran the full suite from the worktree root: python3 scripts/test_agy_hook_adapter.py → 51/51 pass.
  • Confirmed the new/changed tests actually discriminate: reverted plugins/ai-config/claude-hook-adapter.py to the pre-fix b4ca8476 content and re-ran the suite — 9 failures + 3 errors, covering all six fix areas (cwd × 4, dual-case Subagents, matches_tool diagnostic, empty-matcher match-all, nested-context × 2, configurable-caps × 3) — then restored the fixed file and confirmed a byte-identical, no-diff tree.
  • Spot-checked runtime behavior directly: dynamically loaded the module with AGY_ADAPTER_MSG_BYTE_CAP=5/AGY_ADAPTER_TOTAL_BYTE_CAP=8/AGY_ADAPTER_MSG_CAP=3 and confirmed the constants pick up the override; ran the adapter as a real subprocess with AGY_ADAPTER_MSG_CAP=0 against a PreInvocation payload and confirmed no crash ({"injectSteps": []}).
  • Checked deleted lines (git diff ... | grep '^-') on both files: no load-bearing logic or test coverage was dropped — the one removed assertion (os.path.isabs(...)) was replaced by a strictly stronger equality check in the same test.
  • Confirmed repo_root's two legitimate uses (locating hooks/hooks.json, rewriting ${CLAUDE_PLUGIN_ROOT}) are unchanged.
  • Checked the actual hooks/hooks.json catalog: no group currently has an absent/empty matcher, so finding 4's fix changes no live dispatch today (matches the commit message's own claim, which I verified independently rather than trusting). Also checked that none of the current Stop/UserPromptSubmit hook scripts rely on cwd being repo_root-relative — one (no-unmonitored-pr.py) already falls back to os.getcwd() internally and benefits from, rather than is broken by, this change.
  • Checked invoke_subagent's dual-case handling against edge cases (Subagents: [], Subagents: "", Subagents: false, both keys present) by reading the code path; each falls through to a sensible, non-crashing disposition.

Verdict: Ready for merge

Reviewed-Commit: fa34dd9

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Adversarial micro confirmation review — dispatched adversarial-reviewer subagent (Claude, Sonnet) on the addendum delta fa34dd9f..25121347 (comment/docs only, addressing the prior review's two minor findings). Report published verbatim below.


Summary of Changes

Single commit (25121347) touching two files, both comment/documentation prose:

  1. plugins/ai-config/claude-hook-adapter.py — reworded the comment above the tool_cwd = args.get("Cwd") or os.getcwd() line (no code changed) to soften "must be" to "should be" and add a note that the os.getcwd() fallback is best-effort, citing Antigravity's documented $HOME-cwd launch bug from memories/antigravity.md.
  2. memories/antigravity.md — updated the UserPromptSubmit hook-output description to (a) name all three fields the adapter reads (systemMessage, top-level additionalContext, nested hookSpecificOutput.additionalContext), and (b) split out and describe the four AGY_ADAPTER_* override env vars for the injection caps.

Findings

No actionable findings identified.

Verdict: Ready for merge

Reviewed-Commit: 25121347e3f7ecf6c211875b39c0999331cbb4a4

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

ARD dispositions for the six-finding review round at b4ca8476 (all pushed in b4ca8476..25121347):

# Finding Disposition Action
1 cwd fallback regressed to repo_root instead of the caller's cwd Addressed tool_cwd = args.get("Cwd") or os.getcwd() reused at every PreToolUse dispatch site; Stop/PreInvocation pass os.getcwd() directly; repo_root kept only for locating hooks/hooks.json and ${CLAUDE_PLUGIN_ROOT} rewriting. Four end-to-end tests assert the actual cwd= value with os.getcwd mocked to a sentinel; negative control (reverting to repo_root) fails them.
2 Subagents lookup single-case and fail-closed Addressed Explicit-None dual-case lookup (Subagents then subagents); an explicit empty list is allowed rather than denied. Negative controls show both the old single-case form and the tempting or-based form fail the new tests.
3 matches_tool() silently swallowed re.error Addressed stderr diagnostic naming the bad pattern before returning False, matching the file's other handlers; test asserts both the return and the diagnostic.
4 Empty/absent matcher returned False instead of match-all Addressed if not matcher_pattern: return True, verified against Claude Code's documented matcher semantics (omitted/""/"*" all match-all). No current hooks.json group lacks a matcher, so no live dispatch changes today.
5 Stop/PreInvocation read only top-level additionalContext Addressed Both branches now also read nested hookSpecificOutput.additionalContext, preferring top-level when both present, mirroring the PreToolUse branch; two tests plus negative controls.
6 Hardcoded caps (50/10000/30000/20) Addressed Hoisted to module constants overridable via AGY_ADAPTER_FANOUT_CAP, AGY_ADAPTER_MSG_BYTE_CAP, AGY_ADAPTER_TOTAL_BYTE_CAP, AGY_ADAPTER_MSG_CAP through a fail-safe _int_env() (malformed value logs a diagnostic and falls back to the default). Three tests cover override, malformed, and unset cases.

The pre-push adversarial review's own two minor findings (overclaiming cwd comment; memories/antigravity.md stale against the new caps) were both Addressed in the addendum commit 25121347, which the micro confirmation review above cleared. Full suite: 51/51; link check clean. CI and the repo reviewer will now run on 25121347.

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

claude added 3 commits August 27, 2026 01:48
Two execution-confirmed bugs from the review at b4ca847 (round 2):

1. PreInvocation crashed with an uncaught AttributeError and emitted no
   output at all when a hook's parsed `systemMessage` /
   `additionalContext` / nested `hookSpecificOutput.additionalContext`
   value was non-string (dict/list/number). The json.loads() reassigning
   `text_out` was inside a try/except, but the subsequent
   `.encode("utf-8")` was not, so `text_out.encode(...)` died on a dict
   with no JSON ever printed to stdout. Fixed by coercing a non-string
   truthy `text_out` via `str(...)`, mirroring the coercion the
   PreToolUse and Stop branches already apply to their own
   systemMessage/additionalContext reads, and by wrapping the
   byte-capping logic in its own try/except so any further surprise in
   that block still emits valid JSON instead of dying with a traceback.

2. The total-byte-cap boundary logic appended an empty `trimmed_chunk`
   unconditionally when `remaining_bytes` landed mid a multi-byte UTF-8
   character: `errors="ignore"` drops the incomplete trailing byte(s),
   producing "", which was still appended as another empty
   `ephemeralMessage` step and added 0 bytes to the running total -- so
   every remaining hook (up to the message cap) contributed another
   empty step. Fixed by only appending when `trimmed_chunk` is
   non-empty, and breaking out of further accumulation once no more
   content can fit (remaining_bytes exhausted, or trimmed to empty).
   The exact-boundary behavior `test_pre_invocation_thirty_kb_exact_boundary`
   covers is unchanged.

Also adds a code comment at each of the three `hook.get("command") or
hook.get("script")` extraction sites noting that `script` is a legacy
diagnostic basename (never a runnable command line) and `command` is
the canonical field -- a non-blocking note from the same review, no
behavior change.
Adds regression tests for both bugs fixed in the prior commit, each
verified to fail on the pre-fix code and pass on the fix (negative
controls run and reverted; see PR discussion for the transcripts):

- test_pre_invocation_non_string_additional_context_does_not_crash:
  a hook returning `{"additionalContext": {"nested": "object"}}` must
  not crash the adapter and must emit a coerced-to-string
  ephemeralMessage instead.

- test_pre_invocation_multibyte_boundary_no_empty_ephemeral_messages:
  reproduces the reviewer's scenario (hooks consuming 29999 of the
  30000-byte total cap, then hooks returning 2-byte UTF-8 characters
  that cannot fit in the single remaining byte) and asserts no empty
  ephemeralMessage entries appear, that accumulation stops once nothing
  more can fit, and that the byte total lands exactly at 29999.
…t, byte-cap continue)

Correct the three extraction-site comments: `script` is load-bearing for
install-hooks.py's non-plugin path per hooks.json's own header, not a
legacy/informational field -- it is simply not runnable by this adapter.

Change the byte-cap boundary handling so an unfittable multi-byte chunk
skips itself (continue) instead of ending accumulation (break): a later
hook whose output starts with a narrower code point can still fill the
1-3 leftover budget bytes. A full cap still breaks. The boundary
regression test now asserts the leftover byte is actually used by a
subsequent ASCII hook and that all hooks run.

Copy link
Copy Markdown
Collaborator

Adversarial pre-push review — dispatched adversarial-reviewer subagent (Claude, Sonnet), briefed with the diff 25121347..c917dfd7 and the repo standards only. Report published verbatim below.


Summary of Changes

Two commits on top of 25121347 in plugins/ai-config/claude-hook-adapter.py, plus a purely-additive test commit (95 insertions, 0 deletions) in scripts/test_agy_hook_adapter.py, addressing a prior review round's two bugs:

  1. PreInvocation crash fix: text_out (derived from a hook's parsed systemMessage/additionalContext/nested hookSpecificOutput.additionalContext) is now coerced with str(...) when truthy and not already a str, mirroring the existing coercion in the PreToolUse (str(hook_out.get("systemMessage"))) and Stop (str(msg)) branches. The whole byte-accumulation block is also now wrapped in try/except Exception, printing to stderr on failure instead of propagating.
  2. Empty-message boundary fix: in the total-byte-cap trimming branch, trimmed_chunk is now appended only if trimmed_chunk: (non-empty), and the loop breaks once either trimmed_chunk came back empty (mid-multi-byte-character truncation) or total_injected_bytes has reached the cap exactly.
  3. Note: identical comments added at the three hook.get("command") or hook.get("script") extraction sites (PreToolUse, Stop, PreInvocation), asserting script is "a bare diagnostic basename ... (a legacy/informational field)."

Two new regression tests were added: one drives the exact scenario from the round-2 review (29999/30000 bytes consumed, then a hook returning "é"*10) and asserts no empty ephemeralMessage entries and that a 5th, unreachable hook's command is never invoked; the other confirms a dict-valued additionalContext no longer crashes and is rendered via str(...).

Findings

  1. [Convention] (minor) — plugins/ai-config/claude-hook-adapter.py:281-286, 338-341, 421-424. The new comments assert script is "a legacy/informational field" in hooks.json. This contradicts hooks/hooks.json's own _comment header (lines 2-15), which states the file is "dual-purpose" and that scripts/install-hooks.py "reads it to register the same hooks into ~/.claude/settings.json" and "rebuilds its own $HOME/.claude command from the preserved script key." script is therefore a currently load-bearing field for a real, active consumer (the non-plugin install path), not a legacy or purely informational one — it is only unused by claude-hook-adapter.py's own execution path. The rest of each comment (bare basename, never a runnable command line, falls through to run_hook_command and fails unless coincidentally on PATH) is accurate; only the "(a legacy/informational field)" characterization is wrong. No behavior change results — comment-only.

  2. [Edge Case] (minor) — plugins/ai-config/claude-hook-adapter.py:479-494. When a chunk's leading bytes don't form a complete UTF-8 code point within the remaining budget (trimmed_chunk is empty), the loop breaks unconditionally even when total_injected_bytes < PRE_INVOCATION_TOTAL_BYTE_CAP (up to 3 bytes of budget can remain, since UTF-8 characters are at most 4 bytes). A later hook in hooks_to_run whose output happens to start with an ASCII byte could still use that leftover 1-3 bytes, but is never attempted because the loop has already terminated on the current hook's inability to fit. This is not the bug the round targeted (no empty messages are appended, no crash, no infinite loop — hooks_to_run is finite regardless), but it is a stricter-than-necessary fix: continue (skip appending, keep looping) rather than break in the not trimmed_chunk sub-case would let subsequent hooks compete for the same tiny remainder without reintroducing the empty-append bug, since the append is already gated on if trimmed_chunk:. Impact is bounded to at most 3 bytes of a 30000-byte budget, so this is non-blocking.

Both fixes were verified to actually cause the failures they claim to fix: reverting plugins/ai-config/claude-hook-adapter.py to its pre-fix state (25121347) while keeping the new tests reproduces both — test_pre_invocation_non_string_additional_context_does_not_crash raises AttributeError: 'dict' object has no attribute 'encode' at the old text_out.encode("utf-8") call, and test_pre_invocation_multibyte_boundary_no_empty_ephemeral_messages fails its assertNotIn("", messages) assertion. Restoring HEAD afterward left the tree byte-identical (clean git status/git diff). test_pre_invocation_thirty_kb_exact_boundary was traced by hand against the new code and confirmed to still produce 3 steps totaling exactly 30000 bytes. The full suite (python3 scripts/test_agy_hook_adapter.py) passes 53/53. The diff's only deleted lines (git diff 25121347..HEAD | grep '^-[^-]') are the exact block being replaced by the fixed version — no tests or unrelated logic were dropped. No behavior changed outside the two targeted fixes and the three added comments.

Verdict: Ready for merge

Reviewed-Commit: c917dfd

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Adversarial micro confirmation review — dispatched adversarial-reviewer subagent (Claude, Sonnet) on the addendum delta c917dfd7..0bde41b1, which addresses the prior review's two minor findings. Report published verbatim below.


Summary of Changes

Single commit addressing two minor findings from a prior clean review of plugins/ai-config/claude-hook-adapter.py: (1) three duplicated code comments were corrected to state that script remains load-bearing for install-hooks.py's non-plugin path (per hooks/hooks.json's own _comment header) rather than calling it a legacy field; (2) the PreInvocation byte-cap accumulation loop now continues past a hook whose output cannot fit even one code point in the remaining budget (a multi-byte UTF-8 boundary), instead of breaking the whole loop, so a later hook with a narrower/ASCII leading byte can still fill the leftover space. The exact-cap-full case still breaks. scripts/test_agy_hook_adapter.py's regression test was updated to add a fifth, ASCII-output hook and assert all five hooks run, the fourth (unfittable) contributes nothing, and the fifth's "Z" fills the last byte.

Findings

No actionable findings identified.

Verdict: Ready for merge

Reviewed-Commit: 0bde41b

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

ARD dispositions for the two-bug review round at 25121347 (all pushed in 25121347..0bde41b1):

# Finding Disposition Action
1 PreInvocation crashes with uncaught AttributeError (no JSON output) on a non-string systemMessage/additionalContext/nested value Addressed str(...) coercion mirroring the PreToolUse/Stop siblings, plus a defensive try/except around the byte-accumulation block that logs to stderr and keeps emitting valid JSON. Regression test drives a dict-valued additionalContext; negative control reproduces the exact AttributeError on the old code.
2 Byte-cap boundary appends empty ephemeralMessage steps that never advance the total Addressed Append only a non-empty trimmed_chunk; an exactly-full cap breaks, and an unfittable multi-byte chunk now continues so a later ASCII hook can fill the 1-3 leftover bytes (the pre-push review's own refinement). Regression test reproduces the 29999/30000 + "é" scenario and asserts the leftover byte is filled; the pre-existing exact-boundary test passes unchanged.
3 (Non-blocking) script-vs-command extraction wanted a clarifying comment Addressed Comments added at all three sites, corrected per the pre-push review to note script stays load-bearing for install-hooks.py's non-plugin path rather than being legacy.

Both pre-push adversarial rounds above are clean at the pushed head 0bde41b1; full suite 53/53, link check clean. CI and the repo reviewer now run on the new head.

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

claude added 2 commits August 27, 2026 02:52
…timeout

Three review findings from PR #2233's pre-push review:

1. `invoke_subagent`'s `isolation` field was set unconditionally from
   Antigravity's `Workspace` concept (values like "share"/"branch"), which
   is not the same enum as Claude Code's `isolation` mode
   ("worktree"/"remote"). hooks/flag-unassigned-worktree.py gates its
   warning on the truthiness of `isolation`, so any non-empty Workspace
   value silently suppressed that warning for every subagent launch.
   Add normalize_isolation(), which maps through only
   "worktree"/"remote" (case-insensitively) and returns None otherwise;
   the raw Workspace value is preserved under a separate "workspace" key
   for any downstream consumer.

2. The PreInvocation JSON-parse fallback was the file's only
   `except Exception: pass` with no diagnostic, unlike every sibling
   parse handler. It now logs to stderr while keeping the same raw-text
   fallback behavior.

3. The cmd/timeout resolution block (command-vs-script lookup,
   ${CLAUDE_PLUGIN_ROOT} substitution, timeout parsing with a 30s
   default) was duplicated verbatim at three call sites (PreToolUse,
   Stop, PreInvocation). Extracted into resolve_cmd_and_timeout(),
   used at all three sites with identical default-timeout behavior.

Adds discriminating tests in scripts/test_agy_hook_adapter.py for each
fix (positive + negative cases for isolation mapping, a diagnostic
assertion for the parse fallback, and a cross-event-type default-timeout
check). Verified each by reverting its fix, confirming the new test
fails, then restoring and confirming it passes.
…roup

The PreToolUse matcher was widened from the literal "run_command" to the
regex alternation "run_command|invoke_subagent|send_message|
define_subagent|mcp__github__.*", but whether Antigravity treats
`matcher` as a regex at all is unverified (memories/antigravity.md says
so itself).

Per the pre-push review's de-risk suggestion, split run_command back
into its own hook group with the literal matcher "run_command"
(preserving its exact hooks list, including enforce-mwc-review-gate.py),
and put the new tool names in a second group carrying the regex
alternation for just the new coverage. A wrong regex assumption now
costs only the new coverage, never the pre-existing merge-control gate.

Updates memories/antigravity.md's PreToolUse passage to describe the
two-group shape and the rationale.

scripts/test_agy_hook_adapter.py's adapter dispatch tests exercise
hooks/hooks.json (mocked), not plugins/ai-config/hooks.json, so the
dispatch path needed no test updates for this fix. The prior commit
already added test_plugins_hooks_json_run_command_split_into_its_own_group,
which reads plugins/ai-config/hooks.json directly and asserts the
two-group shape (run_command literal, separate regex group,
enforce-mwc-review-gate.py still attached to run_command); this commit
is what makes that test pass. Verified by reverting hooks.json to the
single-group shape, confirming that test fails, then restoring and
confirming it passes.

Copy link
Copy Markdown
Collaborator

Adversarial pre-push review — dispatched adversarial-reviewer subagent (Claude, Sonnet), briefed with the diff 0bde41b1..289af1bf and the repo standards only. Report published verbatim below.


Summary of Changes

Two commits on top of 0bde41b1, addressing a prior review round on ai-config#2233:

  1. e63ecbe2 — in plugins/ai-config/claude-hook-adapter.py: adds normalize_isolation() so only Antigravity Workspace values matching Claude Code's isolation enum (worktree/remote, case-insensitive) are mapped to the isolation field, with the raw value preserved separately under a new workspace key; adds a stderr diagnostic to the previously-silent except Exception: pass in the PreInvocation JSON-parse fallback; extracts the triplicated cmd/timeout-resolution block into a shared resolve_cmd_and_timeout() helper used at all three call sites (PreToolUse, Stop, PreInvocation). Adds four corresponding regression tests to scripts/test_agy_hook_adapter.py.
  2. 289af1bf — splits plugins/ai-config/hooks.json's single PreToolUse matcher group ("run_command|invoke_subagent|send_message|define_subagent|mcp__github__.*") into a literal "run_command" group (unchanged hook list) plus a separate regex group for the four newer tool names (only claude-hook-adapter.py), and updates memories/antigravity.md to describe the two-group shape and its de-risking rationale. Adds a regression test asserting the split shape.

Findings

No actionable findings identified.

Verification performed:

  • python3 scripts/test_agy_hook_adapter.py — 58/58 pass.
  • python3 -c "import json; json.load(...)" on hooks.json — parses.
  • python3 scripts/check-links.py — 2466 links across 568 files, no breaks.
  • Grep for non-ASCII punctuation in added .md lines — none found.
  • normalize_isolation correctly gates on ("worktree", "remote") case-insensitively; the sole consumer of isolation (hooks/flag-unassigned-worktree.py:111, tool_input.get("isolation") truthiness check) is unaffected by anything else in the diff, and nothing reads the new workspace key yet, so it's a safe additive field.
  • resolve_cmd_and_timeout() is called identically at all three sites (PreToolUse line 337, Stop line 385, PreInvocation line 461), sharing one repo_root and one DEFAULT_HOOK_TIMEOUT = 30.0, matching the pre-refactor per-site behavior exactly.
  • hooks.json: confirmed the run_command group's hook list (enforce-mwc-review-gate.py, claude-hook-adapter.py, same order) is byte-identical to the pre-split single group's list; confirmed all 5 original tool-name alternatives are still present, split 1/4 across the two groups with none dropped. enforce-mwc-review-gate.py's own main() early-returns allow for any tool_call.name != "run_command", so its absence from the second (regex) group is a no-op change, not a regression in merge-control coverage.
  • memories/antigravity.md's new description of the two-group shape (which hooks are in which group, the de-risking rationale, that a wrong regex-support assumption only costs the newer-tool-name coverage) checks out against the actual hooks.json content.
  • Test discrimination: reverted each of the four fix hunks individually (isolation mapping, PreInvocation diagnostic, shared-helper default timeout, hooks.json group split) and confirmed the corresponding new test fails each time with the expected assertion message; restored each and confirmed git status/git diff show a byte-identical clean tree, with the full suite green again.
  • py_compile on both modified Python files — no syntax errors.

Non-blocking style observation (not filed as a finding): the two new sentences added to memories/antigravity.md run 391 and 360 characters on a single line, past the semantic-line-breaks guidance. Each is genuinely one sentence (satisfying what CI actually enforces), and the surrounding pre-existing lines in the same file already use comparably long single-sentence lines, so this is consistent with established file style rather than a new violation.

Verdict: Ready for merge

Reviewed-Commit: 289af1b

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

ARD dispositions for the review round at 0bde41b1 (all pushed in 0bde41b1..289af1bf):

# Finding Disposition Action
1 Workspaceisolation mapping silently suppresses flag-unassigned-worktree.py's warning Addressed normalize_isolation() passes through only worktree/remote (case-insensitive); the raw Antigravity value is preserved under a separate workspace key. Tests: "share" no longer surfaces as isolation; "worktree" still does.
2 Silent except Exception: pass in the PreInvocation parse fallback Addressed stderr diagnostic added, matching the sibling branches; raw-text fallback behavior unchanged.
3 cmd/timeout resolution triplicated Addressed Extracted resolve_cmd_and_timeout() used at all three sites, identical default-timeout behavior, shared comment moved into the helper.
4 Widened hooks.json matcher relies on unverified regex interpretation Addressed (de-risked) Matcher split: run_command back on its own literal matcher with a byte-identical hooks list (so the merge-control gate cannot regress on a wrong regex assumption), new tool names in a separate regex group; memories/antigravity.md updated to the two-group shape.

Pre-push adversarial review above is clean at the pushed head 289af1bf with per-hunk negative controls; suite 58/58, links clean, hooks.json parses. CI and the repo reviewer now run on the new head.

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Final statement, without reference to local test-run counts

Code review of HEAD 289af1bf: no defects identified in the diff itself. That is the entire scope of this claim — it covers only the code I read (plugins/ai-config/claude-hook-adapter.py, plugins/ai-config/hooks.json, memories/antigravity.md), not the PR's GitHub check/CI state.

The PR's check/CI state is a separate fact, established by scripts/check-pr-fully-clean.py (the only instrument that can authorize a claim about it), and that instrument reported the PR's checks as incomplete: the review check was still running and no review comment had yet evaluated this head. That status was unresolved at the time I queried it and requires a fresh query after CI finishes — I am not stating or implying it has since resolved.

Verdict

Code review: no defects identified in HEAD 289af1b. Separately and explicitly: this is not a statement about the PR's CI/check state, which was incomplete per the last status query in this transcript and must be re-queried before any merge-readiness conclusion is drawn.

Reviewed commit: 289af1b

Stopping Point: Not a clean stopping point / work remains queued: re-run scripts/check-pr-fully-clean.py after CI (validate, review/claude-review) finishes before treating this PR as merge-ready.

Reviewed commit: 289af1b

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $2.6847 (review) — run

Copy link
Copy Markdown
Collaborator

Per the user's ruling in the driving session, clean independent adversarial Claude verdicts suffice for this session, superseding cursor[bot]'s standing verdict (whose findings were independently verified addressed in later clean rounds). At head 289af1bf: official Claude review reports no defects at the head (its CI-completeness hedge resolved — all ten check runs completed successfully), adversarial subagent review Ready for merge, suite 58/58. Merging under the standing ai-config mwc grant.

Posted by Claude Code (AI agent) --- not written by a human.


Generated by Claude Code

@d-morrison
d-morrison merged commit 958f5a5 into main Aug 27, 2026
10 checks passed
@d-morrison
d-morrison deleted the check_ai_config_active_hooks branch August 27, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants