[BUG] Environment Variables Cleared When Using Pipe Operator in Bash Tool
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When using the Bash tool to access a custom environment variable, say CLAUDE_AGENT_ID, with a pipe operator, the variable expands to only a newline character instead of its actual value "alpha". The environment variable exists and is visible with env | grep CLAUDE_AGENT_ID, but when piped to another command like wc -c, it loses its value.
What Should Happen?
The CLAUDE_AGENT_ID environment variable should consistently expand to its actual value "alpha" when used in any bash command, including when piped to other commands.
Error Messages/Logs
# Variable exists in environment
$ env | grep CLAUDE_AGENT_ID
CLAUDE_AGENT_ID=alpha
# But expands to only newline when piped
$ echo "$CLAUDE_AGENT_ID" | hexdump -C
00000000 0a |.|
00000001
# Character count shows 1 instead of expected 6
$ echo $CLAUDE_AGENT_ID | wc -c
1
Steps to Reproduce
Please provide clear, numbered steps that anyone can follow to reproduce the issue. Important: Include any necessary code, file contents, or context needed to reproduce the bug. If the issue involves specific files or code, please create a minimal example.
-
In Claude Code, use the Bash tool to echo the CLAUDE_AGENT_ID:
echo $CLAUDE_AGENT_IDResult: Shows "alpha" (appears correct)
-
Use the Bash tool to count characters with a pipe:
echo $CLAUDE_AGENT_ID | wc -cResult: Returns
1(incorrect - should be 6) -
Verify the variable exists:
env | grep CLAUDE_AGENT_IDResult: Shows
CLAUDE_AGENT_ID=alpha(correct) -
Check actual bytes being output:
echo "$CLAUDE_AGENT_ID" | hexdump -CResult: Shows only
0a(newline character) -
Run in a new bash subprocess:
bash -c 'echo "$CLAUDE_AGENT_ID" | wc -c'Result: Returns
6(correct - works in subprocess)
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
No response
Claude Code Version
1.0.128 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
Anything else that might help us understand the issue?
- The bug only occurs when using pipes with the environment variable
- Direct echo without pipes appears to work (shows "alpha")
- The variable works correctly when executed in a new bash subprocess using
bash -c - This appears to be a shell state issue where the variable exists in the environment but isn't properly accessible in the current shell context
-
Use case where discovered: I observed this issue when trying to inject basic authentication credentials for curl commands using environment variables (e.g.,
curl -u "$USERNAME:$PASSWORD" https://api.example.com). The authentication would fail because the variables weren't expanding correctly when piped or used in command substitution. - Date observed: 2025-09-28
Additional Context: Affects More Than Just Pipes
I can confirm this bug and wanted to add that the issue extends beyond just pipe operators - environment variables are also empty when passed to subprocess commands like curl.
Real-World Impact Example
When trying to use an API token in a curl Authorization header:
curl -k https://api.example.com/endpoint \
-H "Authorization: Bearer $MY_API_TOKEN" \
-d "data=value"
Result: Authentication fails because curl receives an empty token, even though:
-
${#MY_API_TOKEN}correctly returns 501 (the token length) -
env | grep MY_API_TOKENshows the full token value
Inconsistency Details
# These work:
$ echo ${#MY_API_TOKEN}
501
$ env | grep MY_API_TOKEN
MY_API_TOKEN=<full token visible here>
# These fail (return 0 or empty):
$ printf '%s' "$MY_API_TOKEN" | wc -c
0
$ curl -H "Authorization: Bearer $MY_API_TOKEN" https://api.example.com
# Returns: 401 Unauthorized (token is empty)
Confirmed Workaround
Writing commands to a script file and executing them works correctly:
# Write to script
cat > test.sh << 'EOF'
#!/bin/bash
curl -H "Authorization: Bearer $MY_API_TOKEN" https://api.example.com
EOF
# Execute script
bash test.sh
# Works correctly - token is properly expanded
This suggests the issue is specifically with how the Bash tool passes environment variables to inline commands vs. script files.
Verified: (the CLAUDE_AGENT_ID doesn't seem to exist anymore, but the same happens with CLAUDE_CODE_ENTRYPOINT)
● Bash(env | grep CLAUDE_CODE_ENTRYPOINT)
⎿ CLAUDE_CODE_ENTRYPOINT=cli
● Bash(echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C)
⎿ 00000000 0a |.|
00000001
● Bash(echo $CLAUDE_CODE_ENTRYPOINT | wc -c)
⎿ 1
● The bug is confirmed. CLAUDE_CODE_ENTRYPOINT exists in the environment (shown by env | grep), but when accessed via
echo "$CLAUDE_CODE_ENTRYPOINT", it returns empty (only a newline). The hexdump shows only 0a (newline) and wc -c shows
1 character.
This indicates environment variables are visible to env but not accessible to shell variable expansion in the Bash
tool's subprocess.
Workaround: advise Claude to always use bash <<'BASH' .... BASH when using the Bash tool.
● Now I'll try the commands again using the heredoc format to work around the escaping bugs:
● Bash(bash <<'BASH'
env | grep CLAUDE_CODE_ENTRYPOINT…)
⎿ CLAUDE_CODE_ENTRYPOINT=cli
● Bash(bash <<'BASH'
echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C…)
⎿ 00000000 63 6c 69 0a |cli.|
00000004
● Bash(bash <<'BASH'
echo $CLAUDE_CODE_ENTRYPOINT | wc -c…)
⎿ 4
● Perfect! The heredoc workaround fixes the issue completely:
Without heredoc:
- hexdump: Shows only 0a (newline)
- wc -c: Shows 1 character
With heredoc:
- hexdump: Shows 63 6c 69 0a ("cli\n")
- wc -c: Shows 4 characters
The bug is confirmed: environment variables aren't properly expanded without using the heredoc format. Your workaround
in ~/.claude/must-read-before.d/using-claude-code-tool/Bash.md successfully addresses this escaping bug.
PreToolUse Hook Workaround for Bash Preprocessing Bugs (v7)
A hook that fixes all known bash preprocessing bugs in Claude Code by wrapping commands in bash -c '...'.
Zero token overhead — hooks run externally before command execution.
v7 Update: "Wrap Everything" Approach
Credit: @AdamScherlis identified a gap in v6's pattern-matching. Commands like echo $CLAUDE_CODE_ENTRYPOINT | wc -c weren't being wrapped because they don't match any detection patterns (no $(), no newlines, no loop keywords) — yet they still trigger the preprocessing bug.
Why Pattern-Matching Failed
v1-v6 tried to detect "problematic" commands by looking for:
-
$(...)command substitution - Newlines in commands
- Loops with pipes (
for ... | ...)
This was ~30 lines of regex that inevitably had blind spots.
The Fix: Wrap Everything
AdamScherlis's suggestion: just wrap ALL commands unconditionally.
Concerns we evaluated:
| Concern | Finding |
|---|---|
| Performance overhead? | Negligible — bash -c adds microseconds |
| Breaks existing commands? | Tested 10,749 real Claude commands — no issues |
| Escape sequence differences? | /bin/sh vs bash differ on some escapes, but Claude uses echo -e or $'...' when it needs escape interpretation — never relies on raw \t in double quotes |
Result: Simpler code, more robust, future-proof against unknown bugs.
GitHub Issues Fixed
| Issue | Problem |
|---|---|
| #11225 | $(...) command substitution mangled |
| #11182 | Multi-line commands have newlines stripped |
| #8318 | Loop variables silently cleared with pipes |
| #8318 | Environment variables cleared with pipes (AdamScherlis) |
| #10014 | For-loop variable expansion issues |
Installation
Step 1: Save the hook
mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/fix-bash-substitution.py << 'EOF'
#!/usr/bin/env python3
"""
Claude Code Bash Hook - Fix preprocessing bugs (v7)
Wraps ALL bash commands in `bash -c '...'` to bypass preprocessing bugs.
Credit: @AdamScherlis for the "wrap everything" approach.
Version: 7 (2025-12-25)
"""
import json
import re
import sys
ESCAPE_MARKERS = ["# no-wrap", "# bypass-hook", "# skip-hook"]
def has_escape_marker(command: str) -> bool:
return any(marker in command for marker in ESCAPE_MARKERS)
def has_control_structures(command: str) -> bool:
"""Detect bash control structures (if/for/while/case)."""
patterns = [
r'\bif\b.*\bthen\b', r'\bfor\b.*\bdo\b', r'\bwhile\b.*\bdo\b',
r'\buntil\b.*\bdo\b', r'\bcase\b.*\bin\b', r'\bfunction\b',
]
return any(re.search(p, command, re.MULTILINE) for p in patterns)
def fix_continuations(command: str) -> str:
"""Add backslash continuations for multi-line commands (quote-aware)."""
if '\n' not in command or has_control_structures(command):
return command
result, in_sq, in_dq, i = [], False, False, 0
while i < len(command):
c = command[i]
if c == "'" and not in_dq and (i == 0 or command[i-1] != '\\'):
in_sq = not in_sq
elif c == '"' and not in_sq and (i == 0 or command[i-1] != '\\'):
in_dq = not in_dq
if c == '\n' and not (in_sq or in_dq):
if i + 1 < len(command) and command[i+1] in ' \t':
if i == 0 or command[i-1] != '\\':
result.append(' \\')
result.append(c)
i += 1
return ''.join(result)
def needs_wrapping(command: str) -> bool:
"""v7: Wrap EVERYTHING except already-wrapped and escape-marked commands."""
stripped = command.strip()
if stripped.startswith("bash -c ") or stripped.startswith("bash -c'"):
return False # Already wrapped
if has_escape_marker(command):
return False # User opt-out
return True # Wrap everything else
def main():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
if data.get("tool_name") != "Bash":
sys.exit(0)
command = data.get("tool_input", {}).get("command", "")
if not command or not needs_wrapping(command):
sys.exit(0)
# Fix continuations (skip for heredocs)
fixed = command if '<<' in command else fix_continuations(command)
# Escape single quotes and wrap
escaped = fixed.replace("'", "'\\''")
wrapped = f"bash -c '{escaped}'"
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"command": wrapped}
}
}))
if __name__ == "__main__":
main()
EOF
Step 2: Make executable
chmod +x ~/.claude/hooks/fix-bash-substitution.py
Step 3: Configure Claude Code
Run /hooks in Claude Code, then:
- Select Add hook
- Select PreToolUse
- Select Bash as the tool matcher
- Enter path:
~/.claude/hooks/fix-bash-substitution.py
Step 4: Restart session
Start a new Claude Code session for the hook to take effect.
Verifying It Works
# All of these should now work correctly:
# Command substitution
echo "Today is $(date +%Y-%m-%d)"
# Multi-line
curl https://api.example.com \
-H "Accept: application/json"
# Loop with pipe
for i in 1 2 3; do echo $i | cat; done
# Environment variable with pipe (the v7 fix)
MY_VAR=hello; echo $MY_VAR | wc -c
Escape Markers
To skip wrapping for a specific command, add a comment:
echo $(date) # no-wrap
echo $(date) # bypass-hook
echo $(date) # skip-hook
Changelog
| Version | Date | Changes |
|---|---|---|
| v7 | 2025-12-25 | "Wrap everything" — @AdamScherlis's approach. Simpler, more robust. |
| v6 | 2025-12-11 | Control structure detection for nested if/for/while |
| v5 | 2025-12-10 | Skip continuation-fixing for control structures |
| v4 | 2025-12-10 | Heredoc detection |
| v3 | 2025-12-10 | Quote-aware continuation fixing |
| v2 | 2025-12-09 | Loop-with-pipe detection |
| v1 | 2025-12-09 | Initial release |
The hook above does not fix this issue, because it doesn't include the "env variable + pipe" failure mode in needs_wrapping().
e.g. echo $CLAUDE_CODE_ENTRYPOINT | wc -c will not get wrapped.
Skipping the needs_wrapping check and wrapping every bash call seems to work fine; are there downsides to this approach?
def needs_wrapping(command: str) -> bool:
"""Always wrap commands to bypass preprocessing bugs."""
stripped = command.strip()
# Already wrapped - skip
if stripped.startswith("bash -c ") or stripped.startswith("bash -c'"):
return False
return True
@AdamScherlis You're absolutely right — updated the hook to v7 with your "wrap everything" approach. Thank you for the catch and the elegant solution.
The Problem You Identified
echo $CLAUDE_CODE_ENTRYPOINT | wc -c wasn't getting wrapped because v6 looked for specific patterns:
-
$(...)— not present - Newlines — not present
- Loop keywords with pipes — not present
Yet it still triggers the preprocessing bug. Pattern-matching will always have blind spots.
Evaluating "Wrap Everything"
Before adopting your approach, I tested potential downsides:
| Concern | Investigation | Result |
|---|---|---|
| Performance? | bash -c overhead |
Microseconds — negligible |
| Breaks commands? | Tested 10,749 real Claude commands from history | Zero issues |
| Escape sequences? | /bin/sh interprets \t differently than bash -c |
Claude uses echo -e or $'...' for escapes — never relies on the affected behavior |
The only "failures" in the test suite were tests with wrong expectations (relying on /bin/sh quirks that differ from bash).
The Fix
def needs_wrapping(command: str) -> bool:
"""v7: Wrap EVERYTHING."""
stripped = command.strip()
if stripped.startswith("bash -c "):
return False # Already wrapped
if has_escape_marker(command):
return False # User opt-out
return True # Wrap everything else
~30 lines of pattern detection → 5 lines. Simpler, more robust, future-proof.
Credit added to the hook source and main comment. Thanks again!