claude-code icon indicating copy to clipboard operation
claude-code copied to clipboard

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

Open andre-c-andersen opened this issue 4 months ago • 5 comments

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:

    echo $CLAUDE_AGENT_ID
    

    Result: Shows "alpha" (appears correct)

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

    echo $CLAUDE_AGENT_ID | wc -c
    

    Result: Returns 1 (incorrect - should be 6)

  3. Verify the variable exists:

    env | grep CLAUDE_AGENT_ID
    

    Result: Shows CLAUDE_AGENT_ID=alpha (correct)

  4. Check actual bytes being output:

    echo "$CLAUDE_AGENT_ID" | hexdump -C
    

    Result: Shows only 0a (newline character)

  5. 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

andre-c-andersen avatar Sep 28 '25 19:09 andre-c-andersen

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.

stockmaj avatar Nov 21 '25 16:11 stockmaj

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 avatar Nov 24 '25 19:11 bukzor

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.

bukzor avatar Nov 24 '25 19:11 bukzor

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

smconner avatar Dec 09 '25 22:12 smconner

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 avatar Dec 25 '25 00:12 AdamScherlis

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

smconner avatar Dec 25 '25 05:12 smconner