Skip to content

fix: always emit required fields in PermissionDecision wire format#50

Merged
guess merged 2 commits into
guess:mainfrom
paperview:fix/allow-deny-wire-format-required-fields
May 22, 2026
Merged

fix: always emit required fields in PermissionDecision wire format#50
guess merged 2 commits into
guess:mainfrom
paperview:fix/allow-deny-wire-format-required-fields

Conversation

@paperview

Copy link
Copy Markdown
Contributor

Summary

When a :can_use_tool callback returns :allow (the documented shorthand for "allow this tool"), the resulting wire response is {"behavior": "allow"} — missing the updatedInput field that Claude CLI ≥ 2.1.76 requires. The CLI rejects the response with ZodError, every tool call fails, and the agent has no way to make progress. The same issue affects {:deny, message: nil} and any Deny struct without a message.

Verified on claude_code 0.36.3 with bundled CLI 2.1.76.

Reproduction

{:ok, session} =
  ClaudeCode.start_link(
    cwd: "/tmp/scratch",
    can_use_tool: fn _input, _id -> :allow end
  )

session
|> ClaudeCode.stream("Read package.json and show me the version")
|> ClaudeCode.Stream.collect()

Every tool call that goes through can_use_tool results in the CLI emitting an error like:

ZodError: [{"code":"invalid_union","errors":[
  [{"expected":"record","code":"invalid_type","path":["updatedInput"],"message":"Invalid input: expected record, received undefined"}],
  [{"code":"invalid_value","values":["deny"],"path":["behavior"],"message":"Invalid input: expected \"deny\""},
   {"expected":"string","code":"invalid_type","path":["message"],"message":"Invalid input: expected string, received undefined"}]
]}]

Reading both branches: the CLI's permission-response schema is a discriminated union where the "allow" arm requires updatedInput (a record) and the "deny" arm requires message (a string). Our {"behavior": "allow"} fails the allow arm on missing updatedInput and the deny arm on the literal mismatch. The agent then fabricates explanations ("I need permission to run this MCP tool", "settings.json must be misconfigured") and stalls.

Root cause

lib/claude_code/hook/permission_decision/allow.ex:

def to_wire(%__MODULE__{} = o) do
  %{"behavior" => "allow"}
  |> Output.maybe_put("updatedInput", o.updated_input)
  |> Output.maybe_put("updatedPermissions", o.updated_permissions)
end

Output.maybe_put/3 skips nil values. The :allow shorthand path is:

def coerce_permission(:allow), do: %PermissionDecision.Allow{}

…which leaves updated_input as nil, so it never lands in the wire payload. The Zod schema on the CLI side treats updatedInput as required.

lib/claude_code/hook/permission_decision/deny.ex has the same shape — a Deny struct without a message would also produce {"behavior": "deny"} and fail validation.

Expected behavior

:allow should produce a wire payload that satisfies the CLI's permission-response schema unconditionally. Either:

  • Always emit updatedInput (default to %{} when nil), or
  • Emit the original tool input (the SDK has it in the request) as updatedInput when the callback didn't provide an override.

For Deny, similarly: emit message (default to "" or some neutral string) when the struct didn't carry one.

Fix

Default updated_input to %{} and message to "" in the two to_wire/1 functions, so the wire payload satisfies the CLI's Zod schema even when the callback doesn't supply those fields. Tests updated.

A richer fix would thread the original tool input through ControlHandler.handle_can_use_tool/3 so Allow.to_wire/1 can emit the actual input when the callback returns bare :allow. That avoids sending an empty map and matches how the Python SDK behaves — leaving that as a follow-up.

Related

  • 0.32.1 changelog says "corrected … typespec for :allow/:deny in hook responses" — the typespec may have been corrected but the wire output wasn't.
  • Affects anyone using :can_use_tool with CLI ≥ 2.1.76. With the bundled CLI version locked at 2.1.76 in 0.36.x this is universal.

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.
@guess

guess commented May 22, 2026

Copy link
Copy Markdown
Owner

@paperview thank you! LGTM. Can you just make sure the checks/tests pass and then i can merge in? 🙏

@paperview

Copy link
Copy Markdown
Contributor Author

@guess sure thing, I believe this is now done

@guess
guess merged commit c60799c into guess:main May 22, 2026
2 checks passed
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.

2 participants