PostToolUse hookSpecificOutput.updatedToolOutput not honored for Bash tool in v2.1.121

Resolved 💬 6 comments Opened Apr 28, 2026 by r10f785c-stack Closed Jun 1, 2026

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:

  1. Run a Bash tool call that emits a secret-pattern string, e.g. echo "STRIPE_SECRET_KEY=sk_live_test_abcdefghijklmnop12345678"
  2. Observe: the hook fires (side-channel log writes timestamp matching the call), emits valid JSON to stdout
  3. 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.updatedToolOutput actually 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

View original on GitHub ↗

This issue has 6 comments on GitHub. Read the full discussion on GitHub ↗