From c109c235c9cc28b6636558d5b2f5746cf4ec07d9 Mon Sep 17 00:00:00 2001 From: Phil Date: Fri, 15 May 2026 10:10:10 -0500 Subject: [PATCH 1/2] fix: always emit required fields in PermissionDecision wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `:allow` from a can_use_tool callback was producing `{"behavior": "allow"}` — no `updatedInput` field. CLI 2.1.76's Zod schema treats `updatedInput` as required on the "allow" arm of the permission-response union, so every tool call routed through can_use_tool fails validation with a ZodError and the agent stalls. The same issue affected Deny — `{"behavior": "deny"}` with no `message` fails the "deny" arm (which requires `message: string`). Fix: default `updated_input` to %{} and `message` to "" in to_wire/1 so the wire payload always satisfies the CLI schema. Callers that supply real values are unaffected. Updated three tests that previously asserted the broken output. --- CHANGELOG.md | 6 ++++++ lib/claude_code/hook/permission_decision/allow.ex | 6 ++++-- lib/claude_code/hook/permission_decision/deny.ex | 6 ++++-- test/claude_code/hook/output_test.exs | 15 ++++++++------- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db1c87c0..30c8f249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`:can_use_tool` callback returning bare `:allow` or unmessaged `:deny` no longer triggers CLI `ZodError`** — `PermissionDecision.Allow.to_wire/1` now always emits `"updatedInput"` (defaulting to `%{}` when nil), and `PermissionDecision.Deny.to_wire/1` now always emits `"message"` (defaulting to `""` when nil). The Claude CLI's permission-response schema (Zod union) treats both fields as required on their respective arms, so omitting them caused every tool call routed through `can_use_tool` to fail validation. Verified against CLI 2.1.76. + ## [0.36.3] - 2026-03-30 | CC 2.1.76 ### Fixed diff --git a/lib/claude_code/hook/permission_decision/allow.ex b/lib/claude_code/hook/permission_decision/allow.ex index 3e74e2cf..5dbc902f 100644 --- a/lib/claude_code/hook/permission_decision/allow.ex +++ b/lib/claude_code/hook/permission_decision/allow.ex @@ -25,8 +25,10 @@ defmodule ClaudeCode.Hook.PermissionDecision.Allow do defstruct [:updated_input, :updated_permissions] def to_wire(%__MODULE__{} = o) do - %{"behavior" => "allow"} - |> Output.maybe_put("updatedInput", o.updated_input) + # `updatedInput` is required by the CLI's permission-response schema (Zod + # union expects it on the "allow" arm). Default to %{} when the callback + # didn't supply a replacement input. + %{"behavior" => "allow", "updatedInput" => o.updated_input || %{}} |> Output.maybe_put("updatedPermissions", o.updated_permissions) end end diff --git a/lib/claude_code/hook/permission_decision/deny.ex b/lib/claude_code/hook/permission_decision/deny.ex index cc886bb7..7a2cfbb6 100644 --- a/lib/claude_code/hook/permission_decision/deny.ex +++ b/lib/claude_code/hook/permission_decision/deny.ex @@ -21,8 +21,10 @@ defmodule ClaudeCode.Hook.PermissionDecision.Deny do defstruct [:message, :interrupt] def to_wire(%__MODULE__{} = o) do - %{"behavior" => "deny"} - |> Output.maybe_put("message", o.message) + # `message` is required by the CLI's permission-response schema (Zod union + # expects a string on the "deny" arm). Default to "" when the callback + # didn't supply a reason. + %{"behavior" => "deny", "message" => o.message || ""} |> Output.maybe_put("interrupt", o.interrupt) end end diff --git a/test/claude_code/hook/output_test.exs b/test/claude_code/hook/output_test.exs index bd8a69a2..be472d60 100644 --- a/test/claude_code/hook/output_test.exs +++ b/test/claude_code/hook/output_test.exs @@ -218,9 +218,9 @@ defmodule ClaudeCode.Hook.OutputTest do end describe "PermissionDecision.Allow.to_wire/1" do - test "basic allow" do + test "basic allow always emits updatedInput (CLI Zod requires it)" do result = Allow.to_wire(%Allow{}) - assert result == %{"behavior" => "allow"} + assert result == %{"behavior" => "allow", "updatedInput" => %{}} end test "allow with updated_input" do @@ -242,6 +242,7 @@ defmodule ClaudeCode.Hook.OutputTest do }) assert result["behavior"] == "allow" + assert result["updatedInput"] == %{} assert result["updatedPermissions"] == perms end @@ -259,9 +260,9 @@ defmodule ClaudeCode.Hook.OutputTest do end describe "PermissionDecision.Deny.to_wire/1" do - test "basic deny" do + test "basic deny always emits message (CLI Zod requires it)" do result = Deny.to_wire(%Deny{}) - assert result == %{"behavior" => "deny"} + assert result == %{"behavior" => "deny", "message" => ""} end test "deny with message" do @@ -286,13 +287,13 @@ defmodule ClaudeCode.Hook.OutputTest do assert result["interrupt"] == true end - test "deny with only interrupt" do + test "deny with only interrupt still emits empty message" do result = Deny.to_wire(%Deny{interrupt: true}) assert result["behavior"] == "deny" assert result["interrupt"] == true - refute Map.has_key?(result, "message") + assert result["message"] == "" end end @@ -324,7 +325,7 @@ defmodule ClaudeCode.Hook.OutputTest do describe "PermissionDecision standalone (can_use_tool)" do test "allow via Output.to_wire" do result = Output.to_wire(%Allow{}) - assert result == %{"behavior" => "allow"} + assert result == %{"behavior" => "allow", "updatedInput" => %{}} end test "deny via Output.to_wire" do From ba1707c5c8bbf6e2d9d0e51b36a0028da6d43779 Mon Sep 17 00:00:00 2001 From: Phil Date: Fri, 22 May 2026 12:17:17 -0500 Subject: [PATCH 2/2] fix checks --- lib/claude_code/hook/permission_decision/allow.ex | 7 +++++-- lib/claude_code/hook/permission_decision/deny.ex | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/claude_code/hook/permission_decision/allow.ex b/lib/claude_code/hook/permission_decision/allow.ex index 5dbc902f..46363e5a 100644 --- a/lib/claude_code/hook/permission_decision/allow.ex +++ b/lib/claude_code/hook/permission_decision/allow.ex @@ -28,7 +28,10 @@ defmodule ClaudeCode.Hook.PermissionDecision.Allow do # `updatedInput` is required by the CLI's permission-response schema (Zod # union expects it on the "allow" arm). Default to %{} when the callback # didn't supply a replacement input. - %{"behavior" => "allow", "updatedInput" => o.updated_input || %{}} - |> Output.maybe_put("updatedPermissions", o.updated_permissions) + Output.maybe_put( + %{"behavior" => "allow", "updatedInput" => o.updated_input || %{}}, + "updatedPermissions", + o.updated_permissions + ) end end diff --git a/lib/claude_code/hook/permission_decision/deny.ex b/lib/claude_code/hook/permission_decision/deny.ex index 7a2cfbb6..df03d04e 100644 --- a/lib/claude_code/hook/permission_decision/deny.ex +++ b/lib/claude_code/hook/permission_decision/deny.ex @@ -24,7 +24,6 @@ defmodule ClaudeCode.Hook.PermissionDecision.Deny do # `message` is required by the CLI's permission-response schema (Zod union # expects a string on the "deny" arm). Default to "" when the callback # didn't supply a reason. - %{"behavior" => "deny", "message" => o.message || ""} - |> Output.maybe_put("interrupt", o.interrupt) + Output.maybe_put(%{"behavior" => "deny", "message" => o.message || ""}, "interrupt", o.interrupt) end end