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..46363e5a 100644 --- a/lib/claude_code/hook/permission_decision/allow.ex +++ b/lib/claude_code/hook/permission_decision/allow.ex @@ -25,8 +25,13 @@ 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) - |> Output.maybe_put("updatedPermissions", o.updated_permissions) + # `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. + 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 cc886bb7..df03d04e 100644 --- a/lib/claude_code/hook/permission_decision/deny.ex +++ b/lib/claude_code/hook/permission_decision/deny.ex @@ -21,8 +21,9 @@ defmodule ClaudeCode.Hook.PermissionDecision.Deny do defstruct [:message, :interrupt] def to_wire(%__MODULE__{} = o) do - %{"behavior" => "deny"} - |> Output.maybe_put("message", o.message) - |> Output.maybe_put("interrupt", o.interrupt) + # `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. + Output.maybe_put(%{"behavior" => "deny", "message" => o.message || ""}, "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