Skip to content

Commit c60799c

Browse files
authored
fix: always emit required fields in PermissionDecision wire format (#50)
* fix: always emit required fields in PermissionDecision wire format 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. * fix checks
1 parent ce727de commit c60799c

4 files changed

Lines changed: 26 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- **`: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.
13+
814
## [0.36.3] - 2026-03-30 | CC 2.1.76
915

1016
### Fixed

lib/claude_code/hook/permission_decision/allow.ex

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ defmodule ClaudeCode.Hook.PermissionDecision.Allow do
2525
defstruct [:updated_input, :updated_permissions]
2626

2727
def to_wire(%__MODULE__{} = o) do
28-
%{"behavior" => "allow"}
29-
|> Output.maybe_put("updatedInput", o.updated_input)
30-
|> Output.maybe_put("updatedPermissions", o.updated_permissions)
28+
# `updatedInput` is required by the CLI's permission-response schema (Zod
29+
# union expects it on the "allow" arm). Default to %{} when the callback
30+
# didn't supply a replacement input.
31+
Output.maybe_put(
32+
%{"behavior" => "allow", "updatedInput" => o.updated_input || %{}},
33+
"updatedPermissions",
34+
o.updated_permissions
35+
)
3136
end
3237
end

lib/claude_code/hook/permission_decision/deny.ex

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ defmodule ClaudeCode.Hook.PermissionDecision.Deny do
2121
defstruct [:message, :interrupt]
2222

2323
def to_wire(%__MODULE__{} = o) do
24-
%{"behavior" => "deny"}
25-
|> Output.maybe_put("message", o.message)
26-
|> Output.maybe_put("interrupt", o.interrupt)
24+
# `message` is required by the CLI's permission-response schema (Zod union
25+
# expects a string on the "deny" arm). Default to "" when the callback
26+
# didn't supply a reason.
27+
Output.maybe_put(%{"behavior" => "deny", "message" => o.message || ""}, "interrupt", o.interrupt)
2728
end
2829
end

test/claude_code/hook/output_test.exs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@ defmodule ClaudeCode.Hook.OutputTest do
218218
end
219219

220220
describe "PermissionDecision.Allow.to_wire/1" do
221-
test "basic allow" do
221+
test "basic allow always emits updatedInput (CLI Zod requires it)" do
222222
result = Allow.to_wire(%Allow{})
223-
assert result == %{"behavior" => "allow"}
223+
assert result == %{"behavior" => "allow", "updatedInput" => %{}}
224224
end
225225

226226
test "allow with updated_input" do
@@ -242,6 +242,7 @@ defmodule ClaudeCode.Hook.OutputTest do
242242
})
243243

244244
assert result["behavior"] == "allow"
245+
assert result["updatedInput"] == %{}
245246
assert result["updatedPermissions"] == perms
246247
end
247248

@@ -259,9 +260,9 @@ defmodule ClaudeCode.Hook.OutputTest do
259260
end
260261

261262
describe "PermissionDecision.Deny.to_wire/1" do
262-
test "basic deny" do
263+
test "basic deny always emits message (CLI Zod requires it)" do
263264
result = Deny.to_wire(%Deny{})
264-
assert result == %{"behavior" => "deny"}
265+
assert result == %{"behavior" => "deny", "message" => ""}
265266
end
266267

267268
test "deny with message" do
@@ -286,13 +287,13 @@ defmodule ClaudeCode.Hook.OutputTest do
286287
assert result["interrupt"] == true
287288
end
288289

289-
test "deny with only interrupt" do
290+
test "deny with only interrupt still emits empty message" do
290291
result =
291292
Deny.to_wire(%Deny{interrupt: true})
292293

293294
assert result["behavior"] == "deny"
294295
assert result["interrupt"] == true
295-
refute Map.has_key?(result, "message")
296+
assert result["message"] == ""
296297
end
297298
end
298299

@@ -324,7 +325,7 @@ defmodule ClaudeCode.Hook.OutputTest do
324325
describe "PermissionDecision standalone (can_use_tool)" do
325326
test "allow via Output.to_wire" do
326327
result = Output.to_wire(%Allow{})
327-
assert result == %{"behavior" => "allow"}
328+
assert result == %{"behavior" => "allow", "updatedInput" => %{}}
328329
end
329330

330331
test "deny via Output.to_wire" do

0 commit comments

Comments
 (0)