Skip to content

Make hook decisions take effect in both clients - #2

Open
mariuszs wants to merge 5 commits into
markpollack:mainfrom
mariuszs:send-hook-output-as-is
Open

mariuszs wants to merge 5 commits into
markpollack:mainfrom
mariuszs:send-hook-output-as-is

Conversation

@mariuszs

@mariuszs mariuszs commented Oct 6, 2026 •

Copy link
Copy Markdown

Summary

A PreToolUse deny returned through HookOutput.hookSpecificOutput did not stop the tool. Both clients flattened the hook output into snake_case keys and always sent "continue", null when the hook left it unset. CLI 2.1.291 rejects that payload (ZodError … "path": ["continue"], "message": "Invalid input: expected boolean, received null"), logs Error in hook callback, and runs the tool anyway. updatedInput and additionalContext were dropped the same way.

 hook_callback response
-  {"continue": null, "permission_decision": "deny", "permission_decision_reason": "…"}
+  {"hookSpecificOutput": {"hookEventName": "PreToolUse",
+                          "permissionDecision": "deny", "permissionDecisionReason": "…"}}

HookOutput already carries the CLI's property names and NON_NULL inclusion, so it is now sent as is. Both clients go through one method, HookRegistry.handleCallback.

The async client had a second bug that made its hooks dead before any output was sent. sendControlRequest used an envelope the CLI does not read, so initialize never registered the hooks. interrupt, set_permission_mode and set_model were dropped in the same way:

-{"type": "control",         "request_id": "…", "subtype": "initialize", "hooks": {…}}
+{"type": "control_request", "request_id": "…", "request": {"subtype": "initialize", "hooks": {…}}}

Each fix is in its own commit.

Evidence

HookDecisionIT (@Tag("live"), CLI 2.1.291, bypassPermissions, so only the hook can stop the tool):

Case Before After
sync: deny touch <marker> marker created not created, tool result is_error with the deny reason
async: deny touch <marker> hook never called not created
preToolUseModify rewrites the command original ran rewritten command ran
UserPromptSubmit additionalContext with a codeword model answered NONE model answered the codeword

Deterministic tests, no CLI calls:

  • HookCallbackWireTest: a stub CLI drives both clients through initialize and a hook_callback and records the client's stdin. Against the old clients it failed with but was: {"continue":null,"permission_decision":"deny",…}.
  • HookRegistryTest: checks the response payload for deny, allow, modify, additionalContext, the top-level fields, and an unknown callback ID.

./mvnw clean verify passes. ClaudeAsyncClientIT also still passes live.

Merge Danger

Door: two-way

Blast Radius: hooks, async control requests

  • Hooks that previously had no effect now take effect. A consumer whose hook denies or rewrites a tool will see that tool blocked or rewritten.
  • In the async client, interrupt(), setPermissionMode() and setModel() now actually reach the CLI, and their Mono completes only once the CLI accepts the request. A refusal or a missing reply now fails it with a ClaudeSDKException, as in the sync client, and leaves the current mode or model unchanged.
  • An unknown hook callback ID is now answered with an explicit error, where it used to throw a NullPointerException inside the client.
  • RELEASING.md classifies hook and protocol changes as needing a fresh live run.
  • PR Migrate project to Jackson 3 #1 (Jackson 3): no textual conflict. The new tests import com.fasterxml.jackson.databind, so they would need the same migration as the rest of the suite.

Both clients answered a hook_callback by flattening HookOutput into a
map: hookSpecificOutput became top-level snake_case keys
(permission_decision, permission_decision_reason, updated_input) and
"continue" was always present, null when the hook left it unset.

The CLI validates the response against the hook JSON output format, the
same one a command hook prints: control fields at the top level and
hookSpecificOutput nested with camelCase keys. Against CLI 2.1.291 the
null "continue" fails that validation ("Invalid input: expected
boolean, received null"), the CLI logs "Error in hook callback" and
carries on as if the hook had not answered. A PreToolUse deny therefore
let the tool run, and updatedInput and additionalContext were dropped
the same way. Had "continue" been set, the snake_case keys would still
have been ignored as unknown.

HookOutput already carries the CLI's property names and NON_NULL
inclusion, so it is now sent as is, through one
HookRegistry.handleCallback used by both clients. The async client's
copy had also never forwarded updated_input. An unknown callback ID is
answered with an explicit error instead of a NullPointerException.

HookCallbackWireTest drives each client through a hook_callback round
trip against a stub CLI. HookDecisionIT (live) proves against the real
CLI that a deny stops a Bash command, that updatedInput replaces it,
and that UserPromptSubmit additionalContext reaches the model; all
three failed before this change.
DefaultClaudeAsyncClient.sendControlRequest wrote
{"type":"control","request_id":...,"subtype":...} with the payload
flattened beside the envelope. The CLI reads only
{"type":"control_request","request_id":...,"request":{...}}, the shape
the sync client sends, and drops anything else without a reply.

So the async client's initialize never registered its hooks: against
CLI 2.1.291 a PreToolUse hook on Bash was never called, and the command
ran. interrupt, set_permission_mode and set_model went out in the same
envelope and were dropped the same way.

The envelope now matches the sync client. The request stays
fire-and-forget, but it is registered in pendingResponses, so the CLI's
reply is matched instead of being logged as a response to an unknown
request, and an error reply is logged as a warning.

HookCallbackWireTest checks for both clients that hooks are registered
with a control_request initialize. HookDecisionIT (live) gains the
async deny case, which failed before this change because the hook was
never called. ClaudeAsyncClientIT still passes live.
The async client sent the user prompt right after initialize without
waiting for the reply, so the CLI could start the turn before the hooks
were registered, and a refused initialize was only logged. It now waits
for the reply, as the sync client does, and a refusal fails the connect.

The CLI rejects a hookSpecificOutput whose hookEventName is not the
event it asked for and then ignores the whole output, deny included.
A missing name is now filled in from the hook's registration; a name
for another event is logged and sent as is.
@mariuszs

mariuszs commented Oct 6, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit: the async client now awaits the initialize reply
(a refusal fails connect()), and handleCallback fills in a missing
hookEventName from the registration — the CLI drops the whole output otherwise.

Two things I left out of scope, since they predate this PR — happy to follow up separately:

  • setModel / setPermissionMode / interrupt in the async client still
    complete before the CLI replies, so a refusal only shows up in the log.
  • HookOutput.block() sends continue: false, which in the CLI stops the turn,
    not just the tool. That already applied to the sync client; with this PR it
    applies to the async one too. Should block() only deny the tool?

HookRegistry.handleCallback looks the hook up once and runs it through a
shared execute(), and fills in a missing hookEventName with new withers on
HookOutput and HookSpecificOutput instead of positional constructor copies.

The async client registers a Sinks.One per control request instead of a
cached Mono.create with a throwaway subscriber, logs a refusal where the
reply arrives, and fails pending requests on close, so a connect waiting
for initialize is released instead of waiting out the client timeout.

The new tests share their setup through helpers, and the stub-CLI wire
test checks initialize and the deny from one session per client.
…etModel

The async client sent these requests without waiting for the reply, so a
refusal was only logged and the client recorded the new mode or model
anyway. They now complete once the CLI accepts the request and fail with
a ClaudeSDKException when it refuses or does not reply in time, as the
sync client does.

Python SDK @23bb015: differs: the reply timeout is the client timeout, not Python's 60 s, because the sync client already uses it; awaiting the reply and failing on a refusal matches.
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.

1 participant