[BUG] Environment Variables Cleared When Using Pipe Operator in Bash Tool

Resolved 💬 16 comments Opened Sep 28, 2025 by andre-c-andersen Closed Apr 17, 2026
💡 Likely answer: A maintainer (claude[bot], contributor) responded on this thread — see the highlighted reply below.

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.

  1. In Claude Code, use the Bash tool to echo the CLAUDE_AGENT_ID:

``bash
echo $CLAUDE_AGENT_ID
``
Result: Shows "alpha" (appears correct)

  1. Use the Bash tool to count characters with a pipe:

``bash
echo $CLAUDE_AGENT_ID | wc -c
`
Result: Returns
1` (incorrect - should be 6)

  1. Verify the variable exists:

``bash
env | grep CLAUDE_AGENT_ID
`
Result: Shows
CLAUDE_AGENT_ID=alpha` (correct)

  1. Check actual bytes being output:

``bash
echo "$CLAUDE_AGENT_ID" | hexdump -C
`
Result: Shows only
0a` (newline character)

  1. Run in a new bash subprocess:

``bash
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

View original on GitHub ↗

16 Comments

stockmaj · 7 months ago

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_TOKEN shows 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.

bukzor · 7 months ago

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.
bukzor · 7 months ago

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.
smconner · 7 months ago

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:

  1. Select Add hook
  2. Select PreToolUse
  3. Select Bash as the tool matcher
  4. 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 |

AdamScherlis · 6 months ago

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
smconner · 6 months ago

@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!

github-actions[bot] · 5 months ago

This issue has been inactive for 30 days. If the issue is still occurring, please comment to let us know. Otherwise, this issue will be automatically closed in 30 days for housekeeping purposes.

Butanium · 5 months ago

this is still happening

mhelvens · 4 months ago

I just reported #29298, which does appear to be a duplicate of this bug. However, this bug report seems like it's being ignored a bit, maybe? So I don't want to close mine as a duplicate yet. It contains a bunch of test cases which may be helpful.

Also, I get the feeling this bug was fixed at some earlier versions and then reintroduced? Either that or I'm delusional.

in-op · 4 months ago

Still an issue on 2.1.63. On mac

brokensandals · 4 months ago

Just ran into this on macos 26.3, claude code 2.1.71, zsh 5.9 (arm64-apple-darwin25.0). It was very time-consuming for me to troubleshoot why the commands that worked in my terminal didn't work for claude; hope this gets fixed at some point!

A simple workaround that seems to be working for me is to tell claude to use $(printenv VAR) instead of $VAR.

hjtran · 4 months ago

There are workarounds for this - but we're spending quite a bit of time implementing them and deploying them widely in our org. It'd really save us a lot of time and pain if a proper fix could be implemented

shawnz-swiftly · 3 months ago

This appears to have been fixed within the last couple of weeks

Butanium · 3 months ago

Indeed!

windows:

● Bash(export MY_TEST_VAR="hello_world" && echo $MY_TEST_VAR | cat)
  ⎿  hello_world

● Bash(export FOO="bar123" && echo "$FOO" | grep bar)
  ⎿  bar123

● Bash(export MULTI_VAR="test_value" && echo "$MULTI_VAR" | tr '_' '-' | cat)
  ⎿  test-value

● Bash(export A="one" B="two" && echo "$A $B" | wc -w)
  ⎿  2

● All working fine — env vars survive pipes without issue. Looks like the bug is indeed fixed (at least on Windows/Git Bash).

linux


● Bash(echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C)
  ⎿  00000000  63 6c 69 0a                                       |cli.|
     00000004

● Bash(env | grep CLAUDE_CODE_ENTRYPOINT)
  ⎿  CLAUDE_CODE_ENTRYPOINT=cli

● Bash(claude --version 2>/dev/null || echo "version unknown")
  ⎿  2.1.79 (Claude Code)

● Confirmed fixed on version 2.1.79:

  - CLAUDE_CODE_ENTRYPOINT=cli exists in env
  - echo "$CLAUDE_CODE_ENTRYPOINT" | hexdump -C → 63 6c 69 0a = cli\n (correct!)
  - echo $CLAUDE_CODE_ENTRYPOINT | wc -c → 4 (correct — 3 chars + newline)

  Previously this would show only 0a (empty newline) through the pipe. The bug from issue #8318 appears to be genuinely fixed.
  shawnz-swiftly's comment checks out.
claude[bot] contributor · 3 months ago

This issue was fixed as of version 2.1.79.

github-actions[bot] · 2 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.