claude-code - 💡(How to fix) Fix PostToolUse hookSpecificOutput.updatedToolOutput not honored for Bash tool in v2.1.121 [3 comments, 3 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#54196Fetched 2026-04-29 06:33:44
View on GitHub
Comments
3
Participants
3
Timeline
8
Reactions
0
Timeline (top)
labeled ×4commented ×3cross-referenced ×1

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.

Root Cause

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.

Fix Action

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

Code Example

#!/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

---

"PostToolUse": [{
  "matcher": "Bash|Read|Grep|WebFetch|Glob",
  "hooks": [{ "type": "command", "command": "/abs/path/to/redact-tool-output.sh", "timeout": 5 }]
}]

---

$ 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***"}}
RAW_BUFFERClick to expand / collapse

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

extent analysis

TL;DR

The issue might be due to the hookSpecificOutput.updatedToolOutput not being properly handled for non-MCP tools in v2.1.121, and a workaround could be to use the CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 environment variable.

Guidance

  • Verify that the hookSpecificOutput.updatedToolOutput field is correctly formatted and emitted by the hook, as shown in the direct invocation example.
  • Check the Claude Code documentation for any specific requirements or limitations on using hookSpecificOutput.updatedToolOutput with non-MCP tools.
  • Consider using the CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 environment variable as a temporary workaround to close the secret leak.
  • If possible, test the hook with a later version of Claude Code to see if the issue is resolved.

Example

No code example is provided as the issue seems to be related to the integration of the hook with Claude Code rather than the hook code itself.

Notes

The issue might be specific to the version of Claude Code being used (v2.1.121), and it's unclear if the hookSpecificOutput.updatedToolOutput field is properly supported for non-MCP tools in this version.

Recommendation

Apply the workaround by setting CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, as it provides a temporary solution to close the secret leak, although it may have other implications such as forcing default mode for --dangerously-skip-permissions.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

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.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING