An assistant runs an ordinary command like env — and every key and token you
have is printed straight into the session, in plain text. Anyone with access to
that conversation can read them. And it does not go away: the session is saved
as a file on disk, indexed by memory tools, rolled into overnight digests. That
is how keys leak.
env2hell denies those commands and gives the assistant a safe replacement. Works with Claude Code, Opencode and Codex, on Linux, macOS and Windows.
An app fails with an authorisation error. The developer asks the assistant to work out why the 401. It starts with the sensible thing — checking whether the key is set at all:
env | grep -i apiThe check was right. But grep filters lines, and a line is NAME=value, so
along with the answer "yes, it is set" the value itself is printed into the
session. cat .env while looking at configuration ends the same way, and so
does a plain env when comparing a local run against CI.
From there the line has a life of its own: the session is saved as a file on
disk (Claude Code writes .jsonl), memory tools index it, digests are written
overnight. One command like that costs a rotation of every key on the machine.
This is not hypothetical. It is where this repository came from: five tokens (Z.ai, DeepSeek, OpenRouter, Tavily, plus the assistant's own auth token) printed in full by a single command, in a session whose whole purpose was to name a model version.
Telling the model "don't do that" does not hold. Instructions are advice; this is a gate.
| Command | Purpose |
|---|---|
secrets-guard |
Denies a command before it runs. Assistants call it automatically. |
secrets-redact |
Masks a secret a command printed, before the model reads it. |
safe-env |
Prints the environment with secret values masked. What the model uses instead. |
Plus a rule file (rules/secrets-hygiene.md) installed into each assistant, so
the model knows the replacement exists rather than fighting the block.
env printenv export -p
set declare history
env | grep KEY rtk env ls && env
cat .env head ~/.bashrc cat /proc/1/environ
cat prod.env grep . ~/.aws/credentials
cat ~/.ssh/id_rsa head ~/.aws/credentials
safe-env # every variable, secret values masked
safe-env | grep MODEL # filter it — the values are already masked
echo "$ANTHROPIC_MODEL" # one variable, by name
printenv PATH # one variable, explicit
env VAR=value some-command # sets a variable, dumps nothing
source env/bin/activate # here `env` is a directory name, not a command
git commit -m "fix env parsing" # and here it is just a word in some text
Those last two lines matter more than they look. A guard that cannot tell env
the command from the same word in a directory name or a commit message blocks
your ordinary work on day one, and then you switch it off.
The guard reads the command, so it cannot know what the command will print. A program handed a password prints that password back:
$ croc send report.pdf
On the other computer, run:
croc --relay relay.example:9009 --pass <REDACTED:32> quiet-otter-lamp
secrets-redact replaced that value after croc had already printed it, and
before the model read the result. What it masks:
--pass VALUE password=VALUE TOKEN: VALUE
--token VALUE api_key=VALUE Authorization: Bearer VALUE
ghp_… glpat-… AKIA… eyJ….eyJ…. https://user:pw@host
CREATE USER app IDENTIFIED BY 'VALUE'
What it deliberately leaves alone:
md5sum deadbeefdeadbeefdeadbeefdeadbeef report.pdf
commit feedfacefeedfacefeedfacefeedfacefeedface
An md5 is 32 characters and so was that relay password. Length cannot tell them apart, so the label decides: a high-entropy run is masked only when something on the same line calls it a password, a token, a key or a secret. Masking bare hex everywhere would redact every checksum and commit hash in the session.
git clone <this-repo> env2hell && cd env2hell
./install.sh # every assistant found
./install.sh --dry-run # print what would happen, change nothing
./install.sh --ide claude # one assistant onlyRequires Python 3.8+ (the assistants' configuration files are JSON).
No Python and no Git Bash needed — the guard, the redactor and safe-env all
have PowerShell ports, and the installer is PowerShell too. Until 2026-09-14
only the guard did, so a Windows install had a gate and no net: whatever a
program printed back reached the model untouched. The three ports are held to
the same test suites as the POSIX originals, with --pwsh.
.\install.ps1
.\install.ps1 -DryRun
.\install.ps1 -Ide claudeIf the file does not run, PowerShell's execution policy is blocking it:
powershell -ExecutionPolicy Bypass -File .\install.ps1Restart the assistant afterwards. Hooks and plugins are read once, at start-up; until then nothing is enforced.
Nothing to run by hand. The assistant calls the guard before every shell command; a denied call returns a message telling the model what to use instead:
[secrets-guard] Blocked: bare env/printenv/export/set/declare leaks secrets to
the session. Use `safe-env` (values masked), or `echo "$VAR"` for one value.
Run safe-env yourself whenever you want to see the environment without
putting it in a log:
$ safe-env | grep -E 'TOKEN|MODEL'
ANTHROPIC_MODEL=glm-5.2
GITHUB_PERSONAL_ACCESS_TOKEN=<REDACTED:93>
TAVILY_API_KEY=<REDACTED:57>
The length is kept because it distinguishes two different keys and reveals nothing usable.
The hooks stop a secret from reaching the model. They can do nothing about the ones already on disk. A transcript is written as the session runs and is never rewritten. A value that leaked before the hook covered that case is still sitting in it. Rotation is the only fix, and rotation needs a list.
./tools/scan-transcripts # every store this machine has
./tools/scan-transcripts --quiet # per-file counts, no excerpts
./tools/scan-transcripts --files # names only, for a pipeline
./tools/scan-transcripts --path DIR # somewhere of your own
./tools/scan-transcripts --include-stores # auth.json and friends too/root/.claude/projects/-wp/dc1cc19c…jsonl — 16 line(s) with a credential
/root/.claude/projects/-wp/dc1cc19c…jsonl:214: …url":"https<REDACTED:41>@gitlab…
scanned 454 file(s); 35 with findings, 381 line(s) in total
2 token store(s) skipped — auth.json and the like hold a
credential by design. --include-stores to scan them too.
It reads Claude Code, Codex, Opencode and rtk's tee logs. Three things about it are deliberate:
- it never prints the value. Every excerpt is cut from the masked copy of
the line, so the report shows
<REDACTED:41>and its surroundings. A tool that reports a leak by repeating it has made a second one, into the transcript of whoever ran the scan. - detection is
secrets-redact --filter, the same code path the hook uses. A copy of the patterns here would drift within a release, and a scanner that disagrees with the guard reports clean on exactly the values the guard has just learned to catch. - an assistant's own token store is skipped.
auth.jsonholds a credential because that is its job; a standing entry in every report is how a report stops being read. The skip is counted, not hidden.
Exit status is the interface — 0 nothing found, 1 findings, 2 could not
run — so it fits a cron line or a CI step with no output parsing.
The tool stays in the checkout. It is not installed onto PATH, because it is
something you run when you have a reason to, not something the assistant calls.
./tests/run_all.sh # every suite below and the parity suites, one summary
./tests/run_all.sh guard redact # only the suites with these words in the name
ONLY='UUID' ./tests/run_all.sh redact # only the cases whose label matchesrun_all.sh runs as many suites at once as there are CPUs, the longest first.
ONLY works in the guard and redactor suites. Use it while editing a rule,
and run everything before a release. One suite at a time:
./tests/test_guard.sh # the POSIX guard
./tests/test_redact.sh # the redactor: patterns, result shapes, size
./tests/test_safe_env.sh # safe-env
./tests/test_scan.sh # the transcript scanner
./tests/test_install.sh # the installer, run three times over
./tests/test_guard.sh --pwsh # the same cases against the PowerShell ports
./tests/test_redact.sh --pwsh
./tests/test_safe_env.sh --pwshAll report failed 0.
Run the --pwsh half whenever either side changes. Two implementations of one
rule drift in silence otherwise. secrets-guard.ps1 lost the
command-substitution pass the POSIX guard had and denied a working command.
Nothing ran to notice until the suite was pointed at it.
./uninstall.sh # unwire every assistant, remove both commands
./uninstall.sh --dry-run
./uninstall.sh --keep-bin # unwire only, leave the commands on PATHBackups made at install time are left in place: they hold whatever your configuration looked like before, and removing them here would defeat the purpose of having made them.
| Assistant | Mechanism |
|---|---|
| Claude Code | PreToolUse hook, matcher Bash|Read, in settings.json — a Read is judged by its path |
| Claude Code | PostToolUse hook, matcher Bash|Read|Grep, in settings.json — the redactor |
| Windows | the same two hooks, registered with "shell": "powershell" and pointing at the .ps1 ports |
| Codex | PreToolUse hook, matcher ^Bash$, in hooks.json |
| Codex | PostToolUse hook, matcher ^Bash$ — the redactor, warning only |
| Opencode | permission.bash deny rules and a tool.execute.before plugin, for bash and read |
| Opencode | a tool.execute.after plugin — the redactor |
Opencode needs both layers. permission.bash matches on a command prefix, so
on its own it never sees env | grep X, rtk env or a && env. The plugin
runs the real policy by calling the same secrets-guard, so there is one
source of truth rather than two that drift.
The two matchers differ on purpose. The guard inspects what a call is about
to do: a command line, and the path a Read is about to open — a
known credential store is denied before it is read, the same list cat is
judged by. It does not read the file for a Read, so a file of test keys stays
editable. The redactor inspects a result, and a result carrying a credential
need not have come from a shell at all: Read on a .env, an id_rsa or a kubeconfig hands
the file over verbatim, and Grep returns the matching lines. Reading a secret
needs no shell, so for a while the most direct route to one was the only route
left uncovered.
secrets-redact reaches Claude Code and Opencode by different routes. Claude
Code replaces the result through hookSpecificOutput.updatedToolOutput;
Opencode hands a plugin a mutable output, so the masked string is written back
in place. Both call the same CLI, in --filter mode for the plugin.
Codex cannot replace a result, and the reason is in its source.
PostToolUseOutcome (codex-rs/hooks/src/events/post_tool_use.rs) carries
should_block, additional_contexts and feedback_message — and nothing that
replaces the output. So there the hook does the one thing left: it says a
credential is now in the transcript, through additionalContext.
[secrets-redact] This output contains 1 credential-shaped value(s). Hooks in
this assistant cannot remove it, so it is already in the transcript. Do not
repeat it, do not echo the command that produced it, and tell the user the
value has to be rotated.
The warning names no value and no command. Repeating either would put a second copy in the transcript the warning is about. It does not undo the leak — it turns a silent one into a rotation, which is the difference between finding out now and finding out never.
- How env2hell works — the two passes, and why each exists
- Installation in detail — per assistant, Windows, troubleshooting
- What counts as a secret — the mask list, and how to extend it
-
The guard sees a command before it runs, so it cannot know what the command will print.
secrets-redactcovers that case, but only afterwards: the command has already executed, and the telemetry the assistant sends records the original output. What the model reads is masked; what the vendor logged is not. -
A command that fails is not masked at all, and cannot be. In Claude Code a non-zero exit does not fire
PostToolUse; it firesPostToolUseFailure, whose only documented return field isadditionalContext. There is no slot for a replacement, so the output reaches the model and the transcript intact. env2hell wires a--warn-onlyhook there. It tells the model a credential just landed in the transcript and has to be rotated. A loud leak instead of a silent one — all that event allows.This is not a corner case. A failing command is where credentials surface: a URL carrying a password, an auth error quoting the token, a connection string in a stack trace. It is how the GitLab token that prompted this section reached a transcript in full, from a
git remote -vthat exited 1 because a later command in the same call failed. That class closes one way: keep the credential out of the command's output. A deploy key or a credential helper instead of a token written into a remote URL. -
A file attached with
@is read by the client, not by a tool, so no tool hook sees it. In Claude Code and Codex env2hell blocks such a prompt inUserPromptSubmitwhen the file is a credential store or holds a credential-shaped value; the prompt text, which names only the path, still reaches the transcript. In Opencode the plugin masks the attached content inchat.messageinstead. -
The redactor needs a label. An unlabelled secret that looks like ordinary text — a passphrase of three English words, say — passes through untouched.
-
A labelled value is also judged on its shape, and the shape has a floor. Unquoted, 16 characters or more is masked outright; below that it is masked only when it mixes lower case, upper case and digits.
password=hunter2andtoken: deploy_v2therefore go through. Until 0.9.0 the floor was 16 with nothing underneath it, and a 10-character password in a docker-compose comment reached a transcript on 2026-09-21 — the change came from that leak. Quoting the value removes the doubt: inside quotes the floor is 8 and the composition is not examined. -
What the hook did is in
~/.local/state/env2hell/redact.log, one line per invocation: time, mode, tool, bytes in, whether anything was masked. Never a value. Without it, "the hook never ran", "it ran and matched nothing" and "it ran and the host ignored the replacement" are one observation — nothing — which is what made the 2026-09-21 leak take half a day to explain.ENV2HELL_LOG=offturns it off,ENV2HELL_LOG=<path>moves it. -
It is a filter, not a sandbox. It raises the cost of the common accident; it is not a defence against someone deliberately extracting a value.
-
A key already in the environment stays there. Assistants snapshot their environment at start-up, so removing a variable from
.bashrcdoes not remove it from a running session — see design.
Commit messages are written in English, body included. The history is the only place the reason for a change survives, and whoever can read the code can read the message.
Ten messages from 2026-09-18 were Russian and were rewritten in English on 2026-09-19. Three older ones quote Russian documentation and keep the quotations, because there the Russian is the subject.
The documentation itself is bilingual and stays that way: README.md and
README.RU.md, docs/*.en.md and docs/*.ru.md, edited as pairs.
A pattern is not added without a test naming the shape, and the shapes that
must not fire are tested too. The SQL clause in 0.8.5 shows why. safe-env
had carried that rule since the first commit, the redactor beside it had not,
and nothing was going to notice. Two implementations of one policy drift apart
in silence. For the same reason tools/scan-transcripts calls
secrets-redact --filter instead of holding patterns of its own.
MIT. See LICENSE.