Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions lib/claude_code/hook/permission_decision/allow.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 4 additions & 3 deletions lib/claude_code/hook/permission_decision/deny.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 8 additions & 7 deletions test/claude_code/hook/output_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -242,6 +242,7 @@ defmodule ClaudeCode.Hook.OutputTest do
})

assert result["behavior"] == "allow"
assert result["updatedInput"] == %{}
assert result["updatedPermissions"] == perms
end

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading