Skip to content

Commit 01592ed

Browse files
Eugene Archibaldclaude
andcommitted
fix(VCOP-13): correct Gemini hook contract and apply review feedback
Reviewer B (functionality + reference audit) flagged two Critical bugs that pre-dated this branch but were in scope to fix while we're in the hook code: vibecop's Gemini install wrote a snake_case `before_tool` string, but Gemini CLI requires PascalCase `BeforeTool` as an array of matcher groups (same shape as Claude). Vibecop's Gemini stdin parser read `tool`/`input`/`project`, but real Gemini sends snake_case `tool_name`/`tool_input`/`cwd` (same as Claude). End-to-end, vibecop on Gemini was silently broken before this fix. Verified against github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md. - internal/hooks/install.go: - geminiHooks now uses [BeforeTool array of matcher groups]; install upsert mirrors Claude. - uninstallGeminiHooks strips legacy `before_tool` snake_case key on upgrade. - copilotSettingsPath honors COPILOT_HOME env override (Reviewer B/H1). - upsertCodexEntry / upsertCopilotEntry now return a `changed` bool so installs are idempotent at the file-write level (Reviewer A/H2). - internal/hooks/hooks.go: - GeminiCLIPayload uses `tool_name`/`tool_input`/`cwd`/`session_id`. - normalizeGeminiCLI uses toolInputSummary on the object input. - Auto-detection branches on hook_event_name for snake_case payloads (PermissionRequest → Codex, BeforeTool → Gemini, PreToolUse + turn_id → Codex, else Claude). Removed unused hasAnyKey helper. - Softened normalizeCodex hook_event_name fallback comment to match Codex docs language (Reviewer A/M). - cmd/hook.go: small comment on the unreachable `return nil` (cobra signature requires it). - docs/spec.md: corrected Gemini install example block. - docs/superpowers/specs/...: updated stale Copilot allow shape in the design doc to match implementation (Reviewer B/H3). - Tests: gofmt-clean on touched files; new fixtures cover Gemini wire shape, legacy migration, and the snake_case auto-detection branches; switched new install tests to t.Setenv (Reviewer A nit). go test ./... → 157 pass. go vet ./... → clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9f1dea4 commit 01592ed

10 files changed

Lines changed: 235 additions & 107 deletions

File tree

cmd/hook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ If the daemon is unreachable, exits 0 silently (fail-open).`,
7777
}
7878

7979
os.Exit(hooks.WriteVerdict(detected, nr.Event, resp, os.Stdout, os.Stderr))
80-
return nil
80+
return nil // unreachable; cobra signature requires it
8181
},
8282
}
8383

docs/spec.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,13 @@ After subscribing, the daemon streams newline-terminated event objects to the TU
188188
}
189189
```
190190

191-
**Gemini CLI** (`~/.gemini/settings.json`equivalent hook config):
191+
**Gemini CLI** (`~/.gemini/settings.json``BeforeTool` (PascalCase) hook, array of matcher groups, same shape as Claude):
192192
```json
193193
{
194194
"hooks": {
195-
"before_tool": "vibecop hook"
195+
"BeforeTool": [
196+
{ "hooks": [{ "type": "command", "command": "vibecop hook" }] }
197+
]
196198
}
197199
}
198200
```

docs/superpowers/specs/2026-05-08-per-harness-hook-responses-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Emit the correct **per-harness JSON response** on stdout so that `approve` actua
5555
| `codex`, `PreToolUse` | (no stdout — Codex PreToolUse cannot allow; PermissionRequest is the approval channel) | same JSON shape as Claude PreToolUse deny + stderr | (no stdout) |
5656
| `codex`, `PermissionRequest` | `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` | `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"…"}}}` + stderr | (no stdout) |
5757
| `gemini`, `BeforeTool` | `{"decision":"allow","reason":"…"}` | `{"decision":"deny","reason":"…"}` + stderr | (no stdout) |
58-
| `copilot`, `preToolUse` | `{"permissionDecision":"allow","permissionDecisionReason":"…"}` | `{"permissionDecision":"deny","permissionDecisionReason":"…"}` + stderr | (no stdout) |
58+
| `copilot`, `preToolUse` | `{"permissionDecision":"allow"}` | `{"permissionDecision":"deny","permissionDecisionReason":"…"}` + stderr | (no stdout) |
5959

6060
The `permissionDecisionReason` / `reason` / `message` fields are omitted from JSON when the daemon's reason is empty.
6161

internal/daemon/daemon_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ func TestPermissionRequest(t *testing.T) {
9090
}
9191

9292
func TestPermissionRequestNoHandler(t *testing.T) {
93-
dir := shortTempDir(t)
94-
socketPath := filepath.Join(dir, "d.sock")
93+
dir := shortTempDir(t)
94+
socketPath := filepath.Join(dir, "d.sock")
9595
cfg := config.DefaultConfig()
9696
d := New(socketPath, cfg)
9797
// No handler registered.

internal/hooks/hooks.go

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ type ClaudeCodePayload struct {
1818
Cwd string `json:"cwd,omitempty"`
1919
}
2020

21-
// GeminiCLIPayload is a Gemini CLI before_tool hook payload.
21+
// GeminiCLIPayload is a Gemini CLI BeforeTool hook payload.
22+
//
23+
// Same snake_case shape as Claude — the canonical reference is
24+
// https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md.
2225
type GeminiCLIPayload struct {
23-
HookEventName string `json:"hook_event_name,omitempty"`
24-
Tool string `json:"tool"`
25-
Input string `json:"input"`
26-
Project string `json:"project,omitempty"`
26+
HookEventName string `json:"hook_event_name,omitempty"`
27+
ToolName string `json:"tool_name"`
28+
ToolInput map[string]any `json:"tool_input"`
29+
Cwd string `json:"cwd,omitempty"`
30+
SessionID string `json:"session_id,omitempty"`
2731
}
2832

2933
// CodexPayload is a Codex CLI PreToolUse / PermissionRequest hook payload.
@@ -135,29 +139,30 @@ func DetectAndParse(r io.Reader, harnessHint string) (*NormalizedRequest, string
135139
return nil, HarnessUnknown, fmt.Errorf("unrecognized payload format: %s", truncate(raw, 200))
136140
}
137141

138-
// Codex: hook_event_name == "PermissionRequest" is the unambiguous
139-
// signal — only Codex emits that event. Otherwise Codex-distinctive
140-
// fields (turn_id / transcript_path / model) tell us Codex even when
141-
// hook_event_name is "PreToolUse" (which Claude also uses).
142-
if eventName := stringField(probe, "hook_event_name"); eventName == EventPermissionRequest ||
143-
(eventName == EventPreToolUse && hasAnyKey(probe, "turn_id", "transcript_path", "model")) {
144-
return parseWithFormat(raw, HarnessCodex)
145-
}
146-
147-
// Copilot: camelCase toolName and toolArgs distinguish it from snake_case
142+
// Copilot: camelCase toolName distinguishes it from the snake_case
148143
// payloads. No hook_event_name on the wire.
149144
if _, ok := probe["toolName"]; ok {
150145
return parseWithFormat(raw, HarnessCopilot)
151146
}
152147

153-
// Claude Code: snake_case tool_name + tool_input.
148+
// Claude / Codex / Gemini all use snake_case tool_name + tool_input.
149+
// Disambiguate by hook_event_name, with a Codex-vs-Claude fallback for
150+
// PreToolUse (which both honor) keyed on turn_id — the one Codex-only
151+
// field. transcript_path and model overlap with Claude in newer versions.
154152
if _, ok := probe["tool_name"]; ok {
155-
return parseWithFormat(raw, HarnessClaude)
156-
}
157-
158-
// Gemini CLI: bare "tool" + "input".
159-
if _, ok := probe["tool"]; ok {
160-
return parseWithFormat(raw, HarnessGemini)
153+
switch stringField(probe, "hook_event_name") {
154+
case EventPermissionRequest:
155+
return parseWithFormat(raw, HarnessCodex)
156+
case EventBeforeTool:
157+
return parseWithFormat(raw, HarnessGemini)
158+
case EventPreToolUse:
159+
if _, ok := probe["turn_id"]; ok {
160+
return parseWithFormat(raw, HarnessCodex)
161+
}
162+
return parseWithFormat(raw, HarnessClaude)
163+
default:
164+
return parseWithFormat(raw, HarnessClaude)
165+
}
161166
}
162167

163168
return nil, HarnessUnknown, fmt.Errorf("unrecognized payload format: %s", truncate(raw, 200))
@@ -179,8 +184,8 @@ func parseWithFormat(raw, harness string) (*NormalizedRequest, string, error) {
179184
if err := json.Unmarshal([]byte(raw), &p); err != nil {
180185
return nil, harness, fmt.Errorf("gemini payload: %w", err)
181186
}
182-
if p.Tool == "" {
183-
return nil, harness, fmt.Errorf("gemini payload missing tool")
187+
if p.ToolName == "" {
188+
return nil, harness, fmt.Errorf("gemini payload missing tool_name")
184189
}
185190
return normalizeGeminiCLI(p), harness, nil
186191
case HarnessCodex:
@@ -228,15 +233,15 @@ func normalizeClaudeCode(p ClaudeCodePayload) *NormalizedRequest {
228233

229234
func normalizeGeminiCLI(p GeminiCLIPayload) *NormalizedRequest {
230235
nr := &NormalizedRequest{
231-
Tool: p.Tool,
232-
Input: p.Input,
236+
Tool: p.ToolName,
237+
Input: toolInputSummary(p.ToolInput),
233238
Event: p.HookEventName,
234239
}
235240
if nr.Event == "" {
236241
nr.Event = defaultEventFor(HarnessGemini)
237242
}
238-
if p.Project != "" {
239-
nr.ProjectPath = p.Project
243+
if p.Cwd != "" {
244+
nr.ProjectPath = p.Cwd
240245
} else {
241246
nr.ProjectPath = detectProjectDir()
242247
}
@@ -249,8 +254,9 @@ func normalizeCodex(p CodexPayload) *NormalizedRequest {
249254
Input: toolInputSummary(p.ToolInput),
250255
Event: p.HookEventName,
251256
}
252-
// Codex always sends hook_event_name; if it's missing we still try the
253-
// PreToolUse default rather than failing — fail-open is the contract.
257+
// Codex documents hook_event_name as always present
258+
// (developers.openai.com/codex/hooks); we still default to PreToolUse on
259+
// absence rather than rejecting, to honor fail-open.
254260
if nr.Event == "" {
255261
nr.Event = EventPreToolUse
256262
}
@@ -351,12 +357,3 @@ func stringField(m map[string]json.RawMessage, key string) string {
351357
}
352358
return s
353359
}
354-
355-
func hasAnyKey(m map[string]json.RawMessage, keys ...string) bool {
356-
for _, k := range keys {
357-
if _, ok := m[k]; ok {
358-
return true
359-
}
360-
}
361-
return false
362-
}

internal/hooks/hooks_test.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ func TestDetectClaudeCodeRead(t *testing.T) {
7474

7575
func TestDetectGeminiCLI(t *testing.T) {
7676
input := `{
77-
"tool": "Bash",
78-
"input": "go test ./...",
79-
"project": "/Users/test/src/my-project"
77+
"hook_event_name": "BeforeTool",
78+
"tool_name": "Bash",
79+
"tool_input": { "command": "go test ./..." },
80+
"cwd": "/Users/test/src/my-project"
8081
}`
8182

8283
nr, harness, err := DetectAndParse(strings.NewReader(input), "")
@@ -95,15 +96,22 @@ func TestDetectGeminiCLI(t *testing.T) {
9596
if nr.ProjectPath != "/Users/test/src/my-project" {
9697
t.Errorf("expected /Users/test/src/my-project, got %s", nr.ProjectPath)
9798
}
99+
if nr.Event != EventBeforeTool {
100+
t.Errorf("expected event BeforeTool, got %s", nr.Event)
101+
}
98102
}
99103

100104
func TestDetectWithHint(t *testing.T) {
101-
// Valid Gemini payload forced as Claude — should fail.
102-
input := `{ "tool": "Bash", "input": "echo hi" }`
105+
// Valid Gemini payload forced as Claude — should still parse, since
106+
// both share the snake_case shape; the hint forces claude harness.
107+
input := `{ "hook_event_name": "BeforeTool", "tool_name": "Bash", "tool_input": { "command": "echo hi" } }`
103108

104-
_, _, err := DetectAndParse(strings.NewReader(input), HarnessClaude)
105-
if err == nil {
106-
t.Fatal("expected error when parsing gemini payload as claude")
109+
_, harness, err := DetectAndParse(strings.NewReader(input), HarnessClaude)
110+
if err != nil {
111+
t.Fatalf("hint parse failed: %v", err)
112+
}
113+
if harness != HarnessClaude {
114+
t.Errorf("hint should force claude, got %s", harness)
107115
}
108116
}
109117

@@ -254,16 +262,32 @@ func TestDetectCopilotMalformedToolArgs(t *testing.T) {
254262
}
255263

256264
func TestDetectGeminiSetsBeforeToolEvent(t *testing.T) {
257-
input := `{ "tool": "Bash", "input": "go test ./..." }`
258-
nr, _, err := DetectAndParse(strings.NewReader(input), "")
265+
input := `{ "hook_event_name": "BeforeTool", "tool_name": "Bash", "tool_input": {} }`
266+
nr, harness, err := DetectAndParse(strings.NewReader(input), "")
259267
if err != nil {
260268
t.Fatal(err)
261269
}
270+
if harness != HarnessGemini {
271+
t.Errorf("expected gemini, got %s", harness)
272+
}
262273
if nr.Event != EventBeforeTool {
263274
t.Errorf("expected default BeforeTool, got %s", nr.Event)
264275
}
265276
}
266277

278+
func TestDetectClaudeWhenNoHookEventName(t *testing.T) {
279+
// snake_case payload with tool_name and no hook_event_name → Claude
280+
// (Claude's payload sometimes omits the field on older versions).
281+
input := `{ "tool_name": "Bash", "tool_input": { "command": "ls" } }`
282+
_, harness, err := DetectAndParse(strings.NewReader(input), "")
283+
if err != nil {
284+
t.Fatal(err)
285+
}
286+
if harness != HarnessClaude {
287+
t.Errorf("expected claude default, got %s", harness)
288+
}
289+
}
290+
267291
func TestParseWithFormatCodexMissingEvent(t *testing.T) {
268292
// Codex always sends hook_event_name, but if it's missing we fall to
269293
// the PreToolUse default rather than fail.

0 commit comments

Comments
 (0)