Skip to content

Commit e8c2be9

Browse files
joshwilhelmiclaude
andcommitted
fix(ghook): stop double-emitting Claude PreToolUse denies [project-#147]
For --cli=claude, narrow the legacy stderr+exit(2) channel to responses that explicitly set top-level continue:false with a non-empty stopReason (the HARD_STOP shape). All other responses -- including PreToolUse denies that arrive via hookSpecificOutput.permissionDecision:"deny" -- now go to stdout JSON + exit 0, matching the contract the Python ClaudeCodeAdapter already targets (PRE_TOOL_USE is in DECISION_STYLES_ALLOWED_TO_CONTINUE_ON_DENY, so the daemon never adds the second channel itself). Previously ghook synthesized a second deny channel on top of the structured one, so Claude rendered every PreToolUse deny twice -- once as a permission denial, once as a "hook blocking error". Codex/Gemini/Qwen/Droid paths are unchanged; their is_blocked contract still flows through the legacy tail of action_from_success_response. Bumps crates/ghook to 0.4.1; renames the unreleased CHANGELOG section in place; updates ghook docs version refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 09306b8 commit e8c2be9

6 files changed

Lines changed: 85 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ All notable changes to gobby-cli are documented in this file.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10-
## [0.4.0] — gobby-hooks
10+
## [0.4.1] — gobby-hooks
1111

1212
### Added
1313

@@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
- **Droid blocking semantics** — droid daemon responses with `continue:false` now exit 2 with the daemon reason while preserving the response JSON on stdout. Other droid block JSON is forwarded on stdout with exit 0 for droid's hook protocol, and daemon transport failures surface as exit 1 stderr diagnostics.
2424

25+
### Fixed
26+
27+
#### gobby-hooks
28+
29+
- **Stop double-emitting Claude PreToolUse denies** — for `--cli=claude`, ghook now narrows the legacy `stderr+exit(2)` channel to daemon responses that explicitly set top-level `continue:false` with a non-empty `stopReason` (the HARD_STOP shape). All other responses — including PreToolUse denies that arrive via `hookSpecificOutput.permissionDecision:"deny"` — are emitted as JSON on stdout with exit 0, matching the structured-channel contract the Python `ClaudeCodeAdapter` already targets. Previously, ghook synthesized a second deny channel on top of the structured one, causing Claude Code to render every PreToolUse deny twice (once as a permission denial, once as a "hook blocking error"). Codex/Gemini/Qwen/Droid paths are unchanged.
30+
2531
## [0.3.1] — gobby-hooks
2632

2733
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ghook/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-hooks"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/src/main.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,35 @@ fn action_from_success_response(
323323
return Ok(action_from_droid_success(result, serialized));
324324
}
325325

326+
// Claude carries a structured permissionDecision channel; emitting a
327+
// second stderr+exit(2) channel on top makes Claude render every
328+
// PreToolUse deny twice. Mirror the daemon contract: only the
329+
// top-level continue:false + stopReason shape (HARD_STOP) becomes
330+
// exit 2. Codex/Gemini/Qwen keep the legacy is_blocked path below.
331+
if canonical_source == "claude" {
332+
let map = result.as_object();
333+
let continue_false =
334+
map.and_then(|m| m.get("continue")).and_then(Value::as_bool) == Some(false);
335+
let stop_reason = map
336+
.and_then(|m| m.get("stopReason"))
337+
.and_then(Value::as_str)
338+
.filter(|s| !s.is_empty());
339+
340+
if continue_false && let Some(reason) = stop_reason {
341+
return Ok(HookAction {
342+
exit_code: 2,
343+
stdout_json: None,
344+
stderr_message: Some(reason.to_string()),
345+
});
346+
}
347+
348+
return Ok(HookAction {
349+
exit_code: 0,
350+
stdout_json: json_value_is_meaningful(&result).then_some(serialized),
351+
stderr_message: None,
352+
});
353+
}
354+
326355
if is_blocked(&result) {
327356
if hook_type != "Stop" {
328357
return Ok(HookAction {
@@ -644,6 +673,48 @@ mod tests {
644673
);
645674
}
646675

676+
#[test]
677+
fn action_from_success_claude_hard_stop_exits_two() {
678+
let action = action_from_success_response(
679+
"claude",
680+
"Stop",
681+
r#"{"continue":false,"stopReason":"Daemon halted run"}"#,
682+
)
683+
.unwrap();
684+
685+
assert_eq!(
686+
action,
687+
HookAction {
688+
exit_code: 2,
689+
stdout_json: None,
690+
stderr_message: Some("Daemon halted run".to_string()),
691+
}
692+
);
693+
}
694+
695+
#[test]
696+
fn action_from_success_claude_stop_with_permission_deny_no_exit_two() {
697+
let action = action_from_success_response(
698+
"claude",
699+
"Stop",
700+
r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"r"}}"#,
701+
)
702+
.unwrap();
703+
704+
assert_eq!(action.exit_code, 0);
705+
assert!(action.stdout_json.is_some());
706+
assert_eq!(action.stderr_message, None);
707+
}
708+
709+
#[test]
710+
fn action_from_success_claude_continue_false_without_reason_does_not_exit_two() {
711+
let action =
712+
action_from_success_response("claude", "Stop", r#"{"continue":false}"#).unwrap();
713+
714+
assert_eq!(action.exit_code, 0);
715+
assert_eq!(action.stderr_message, None);
716+
}
717+
647718
#[test]
648719
fn action_from_success_treats_droid_continue_false_as_exit_two_with_json() {
649720
let action = action_from_success_response(

docs/guides/ghook-development-guide.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ Schema:
152152
```json
153153
{
154154
"install_method": "github-release",
155-
"install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.0/ghook-aarch64-apple-darwin.tar.gz",
156-
"installed_version": "0.4.0",
155+
"install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.1/ghook-aarch64-apple-darwin.tar.gz",
156+
"installed_version": "0.4.1",
157157
"installed_at": "2026-04-22T18:30:00Z"
158158
}
159159
```
@@ -318,7 +318,7 @@ Almost always config-only. ghook treats `--type` as opaque. To make a hook criti
318318

319319
## Versioning
320320

321-
ghook is at `0.4.0`. The envelope `SCHEMA_VERSION` is `1`; the diagnose-output schema is `2`. The three version numbers are independent:
321+
ghook is at `0.4.1`. The envelope `SCHEMA_VERSION` is `1`; the diagnose-output schema is `2`. The three version numbers are independent:
322322

323323
- **Crate version** bumps for any code change (binary behavior, dependencies, perf, etc.).
324324
- **Envelope `SCHEMA_VERSION`** bumps only when the inbox envelope shape changes in a way the daemon must explicitly handle.

docs/guides/ghook-user-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Unknown `--cli` values fall back to conservative Claude-like dispatch behavior o
162162
$ ghook --diagnose --cli=claude --type=session-start
163163
{
164164
"schema_version": 2,
165-
"ghook_version": "0.4.0",
165+
"ghook_version": "0.4.1",
166166
"cli": "claude",
167167
"hook_type": "session-start",
168168
"source": "claude",
@@ -182,7 +182,7 @@ $ ghook --diagnose --cli=claude --type=session-start
182182
},
183183
"cli_recognized": true,
184184
"install_method": "github-release",
185-
"install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.0/ghook-aarch64-apple-darwin.tar.gz"
185+
"install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.1/ghook-aarch64-apple-darwin.tar.gz"
186186
}
187187
```
188188

0 commit comments

Comments
 (0)