PostToolUse hookSpecificOutput.updatedToolOutput silently ignored when shape doesn't match tool's native output
Summary
A PostToolUse hook's hookSpecificOutput.updatedToolOutput is only applied when it matches the tool's native output shape. When a hook emits a plain string for a tool whose native output is a structured object (e.g. Read), the replacement is silently dropped — no error, no warning, exit 0, valid JSON on stdout — and the model receives the original, unmodified output instead.
This is a correctness/observability gap: nothing in the hook's exit code, stdout, or any accessible log indicates the replacement was ignored. A hook author who ships a string-shaped updatedToolOutput for Read/WebFetch/etc. will believe their rewrite is live when it silently isn't.
Environment
- Claude Code CLI 2.1.198
- Verified this is not new:
updatedToolOutputfor all tools (previously MCP-tool-only) shipped in 2.1.121, and no changelog entry between 2.1.121 and 2.1.198 renames, rescopes, or removes it. - Reproduced in headless mode (
claude -p),permission_mode: "default", no MCP servers, singlePostToolUsehook onRead, isolated project/settings.
Repro
Setup: a PostToolUse hook on Read that reads the tool's stdin payload and emits:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": "HOOK_REWRITE_MARKER — this text should replace the file content"
}
}
(confirmed invoked — stdin logged to a side file; hook exits 0; stdout is valid JSON matching the schema in the docs)
T1 (string updatedToolOutput, as above): the model's transcript still shows the original file content. The replacement never reaches the model.
T1b (structured updatedToolOutput, mirroring Read's own native output shape):
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": {
"type": "text",
"file": {
"filePath": "...",
"content": "HOOK_REWRITE_MARKER — this text should replace the file content",
"numLines": 1,
"startLine": 1,
"totalLines": 1
}
}
}
}
Identical hook, identical harness, only the shape of updatedToolOutput changed — and this time the model's transcript shows the marker. The replacement is applied.
We independently captured the native output shapes for a few other built-in tools while investigating (for reference, not exhaustive):
WebFetch:{bytes, code, codeText, result, durationMs, url}WebSearch:{query, results: [{content: [{title, url}]}]}— no rewritable text field at all, so a text-replacement hook is structurally impossible for this tool today.
Expected vs actual
- Expected: either (a)
updatedToolOutputaccepts a plain string for any tool and the harness handles the shape adaptation, since the docs describe it generically as "replace tool output" without qualifying it per-tool; or (b) if a shape match is required, a mismatch should be surfaced somehow (stderr note, transcript warning, debug log) rather than silently discarded. - Actual: a shape mismatch is silently ignored with no signal to the hook author or the end user. The only way we found this was building a minimal two-shape A/B repro and diffing the model's transcript.
Impact
For hook authors building output-mitigation/rewriting hooks (e.g. redaction, prompt-injection neutralization, truncation) against non-MCP tools, this is easy to get wrong silently — a hook can pass every self-check (invoked, valid exit code, valid JSON) and still do nothing, with no way to detect the failure short of an end-to-end transcript diff per tool.
Suggested fix
At minimum, log (even at a debug/verbose level) when a updatedToolOutput value's shape doesn't match the tool's expected output shape and is therefore ignored, so hook authors have a way to detect the mismatch other than manual transcript inspection.
Happy to share the minimal repro harness (a handful of hook scripts + settings.json) if useful.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗