PostToolUse hookSpecificOutput.updatedToolOutput not honored for Bash tool in v2.1.121
Description
Per the v2.1.121 changelog: "PostToolUse hooks can now replace tool output for all tools (not just MCP) via hookSpecificOutput.updatedToolOutput."
I built a PostToolUse hook for Bash|Read|Grep|WebFetch|Glob that scrubs secrets from tool output. Hook emits valid JSON. The displayed/persisted tool output is unchanged.
Expected behavior
When a PostToolUse hook emits {"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": "<replacement>"}}, the replacement string should appear in the conversation transcript and in the model's context for subsequent turns.
Actual behavior
Hook fires (confirmed via side-channel log written by hook itself). Hook emits valid JSON via stdout (verified by direct invocation). But the live tool output rendered to the model and persisted in the conversation transcript contains the original, unredacted text.
Reproduction
Hook script (abbreviated):
#!/bin/bash
INPUT_JSON=$(cat)
RESULT=$(HOOK_INPUT="$INPUT_JSON" python3 <<'PYEOF'
import os, sys, re, json
data = json.loads(os.environ['HOOK_INPUT'])
if data.get('tool_name') not in ('Bash','Read','Grep','WebFetch','Glob'): sys.exit(0)
original = data.get('tool_output') or data.get('tool_response') or data.get('output') or ''
if not isinstance(original, str): sys.exit(0)
text = re.sub(r'sk_live_[A-Za-z0-9]{16,}', 'sk_live_***REDACTED***', original, flags=re.DOTALL)
text = re.sub(r'\b(STRIPE_SECRET_KEY|DATABASE_URL)=("?)([^"\s]{8,})\2', r'\1=\2***REDACTED***\2', text, flags=re.DOTALL)
if text == original: sys.exit(0)
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": text}}))
sys.stderr.write(f"hook fired tool={data.get('tool_name')} bytes={len(original)}->{len(text)}\n")
PYEOF
)
[ -n "$RESULT" ] && echo "$RESULT"
exit 0
Wired in ~/.claude/settings.json:
"PostToolUse": [{
"matcher": "Bash|Read|Grep|WebFetch|Glob",
"hooks": [{ "type": "command", "command": "/abs/path/to/redact-tool-output.sh", "timeout": 5 }]
}]
Steps:
- Run a Bash tool call that emits a secret-pattern string, e.g.
echo "STRIPE_SECRET_KEY=sk_live_test_abcdefghijklmnop12345678" - Observe: the hook fires (side-channel log writes timestamp matching the call), emits valid JSON to stdout
- Observe: the rendered tool output in the conversation still shows the unredacted string
Direct invocation works
Calling the hook directly outside Claude Code produces the expected JSON:
$ echo '{"tool_name":"Bash","tool_output":"STRIPE_SECRET_KEY=sk_live_test_abcdefghijklmnop12345678"}' | bash redact-tool-output.sh
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": "STRIPE_SECRET_KEY=***REDACTED***"}}
So the hook is correct; the harness does not appear to consume the directive for the Bash tool path.
Environment
- Claude Code: 2.1.121
- macOS: Darwin 25.4.0 (arm64)
- Node: bundled with Claude Code
- Subscription: Pro / Max (not Team/Enterprise)
Impact
This pattern is the documented mechanism for output-side secret redaction. Without it functioning, transcripts persist raw secrets to disk in ~/.claude/projects/<slug>/*.jsonl, which is the threat model the changelog entry advertised closing.
Question
- Is
hookSpecificOutput.updatedToolOutputactually wired for non-MCP tools in v2.1.121? If yes, what's the expected JSON shape — does it differ from the MCP form? - If the field name or surrounding shape changed in a later version, please update the changelog entry to reflect the correct form.
Workaround
Reverting to CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 (env-side scrub) closes the leak via a different mechanism, at the cost of --dangerously-skip-permissions being silently forced to default mode.
🤖 Filed via Claude Code
This issue has 6 comments on GitHub. Read the full discussion on GitHub ↗