| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
PostToolUse hook for Claude Code that redacts secrets and PII from tool output before Claude sees it. Works for all tools: Read, Bash, MCP tools (mcp__ssh-manager__*, etc.).
Claude Code puts every tool result into the model context. One cat .env, one wg show, one MCP call over SSH, and a live key is inside a transcript that went to a third party. You cannot recall it — you can only rotate the key.
This hook sits between the tool and the model. It cuts the secret out before the model receives it, so the leak does not happen instead of being found afterwards.
It is a last line of defence, not a replacement for keeping secrets out of tool output.
Claude Code runs the hook after every tool call. The hook receives the tool response, applies the enabled rules, and returns the cleaned version.
Every rule has a name, and every replacement says which rule fired:
PGPASSWORD=[REDACTED:env_secret] Authorization: Basic [REDACTED:basic_auth] postgresql://app:[REDACTED:conn_str]@db.local/app
So the model still sees what kind of value was there, and you can read off the exact name to switch off if a rule is wrong for you.
Pure regex. No network call, no model, no dependencies — one file and the standard library, about 1 ms per tool call.
python3 redact_output.py --list-rules prints these with their current state.
| Rule | Cuts | Default |
|---|---|---|
| secret_file | the exact values in redact.secrets, when that file exists | on |
| ssh_key | -----BEGIN … PRIVATE KEY----- blocks, including PGP armour | on |
| putty_ppk | PuTTY Private-Lines: body | on |
| awg_init | AmneziaWG I1 = <b 0x…> fake TLS blobs | on |
| awg_params | AmneziaWG Jc, Jmin, Jmax, S1, S2, H1–H4 | on |
| wg_key | 44-char base64 after PrivateKey/PublicKey/PresharedKey/PeerKey | on |
| hash | bcrypt, argon2, scrypt and crypt(3) hashes — $6$, $1$, $5$, $y$, $apr1$, so /etc/shadow and .htpasswd are covered | on |
| docker_auth | "auth": "…" in config.json | on |
| k8s_key_data | kubeconfig client-key-data, client-certificate-data | on |
| gcp_key_id | "private_key_id" in a service-account JSON | on |
| azure_storage | AccountKey= in a storage connection string | on |
| google_api_key | AIza… | on |
| stripe | sk_live_, rk_live_, pk_test_, … | on |
| digitalocean | dop_v1_, doo_v1_, dor_v1_ | on |
| telegram_bot | 123456789:AA… bot tokens, bare or inside api.telegram.org/bot… | on |
| telegram_session | Telethon and Pyrogram session strings — a full account takeover on their own | on |
| telegram_api_hash | MTProto api_hash / app_hash | on |
| slack_webhook | hooks.slack.com/services/… | on |
| discord_webhook | discord.com/api/webhooks/… | on |
| basic_auth | Authorization: Basic … | on |
| auth_header | X-Api-Key:, Auth-Token:, Access-Secret: … | on |
| cookie | Set-Cookie: values named session, sid, token, csrf, auth | on |
| netrc | password in a machine … line | on |
| cli_userpass | curl -u user:pass | on |
| cli_password | --password=, --token=, --api-key= | on |
| query_param | ?access_token=, &client_secret=, X-Amz-Signature=, code= … in a URL or a urlencoded body; case, -, _ and . in the name do not matter | on |
| pw_command | the literal password given to wgpw, htpasswd, chpasswd, smbpasswd, mkpasswd, openssl passwd | on |
| bip39_seed | 12–24 word wallet seed after mnemonic/seed/recovery phrase | on |
| env_secret | UPPER_SNAKE names ending _PASS, _PWD, _SECRET, _TOKEN, _KEY, _DSN, _PAT … plus PGPASSWORD, MYSQL_PWD | on |
| secret_word | passphrase=, credentials= in any case | on |
| assignment | password=, api_key=, client_secret=, access_token= … | on |
| py_repr | a quoted value after a secret key in a Python repr, a dict literal or JSON: Config(api_key='…'), {'password': '…'}, any characters inside the quotes | on |
| pytest_where | the value in pytest's where '…' = settings.password | on |
| prefix | ghp_, sk-, AKIA, glpat-, SG., npm_, hvs., Bearer , dckr_pat_, xoxb-, pypi-, whsec_ | on |
| conn_str | the password in any scheme://user:pass@host | on |
| jwt | eyJ…eyJ… | on |
| phone | phone numbers with a leading + | on |
| card | 13–19 digits that pass Luhn and start with 3–6 | on |
| mail addresses | off | |
| ssn | US social security numbers | off |
| iban | IBAN account numbers | off |
| home_path | the user name in /home/… and /Users/… | off |
| mac_addr | MAC addresses | off |
| public_ip | IPv4 outside the private ranges | off |
| public_ip6 | IPv6 in global scope — loopback, link-local, unique-local and 2001:db8::/32 are left alone | off |
| phone_loose | phone numbers with no country code | off |
| entropy | assignment values with Shannon entropy ≥ 3.5 | off |
A terminal escape or a lone \r inside a token, as grep --color and progress output leave them, is removed before the rules run, so a split key is still cut. Colour around whole words is kept.
The rules are tuned against false positives, and the self-check enforces it: re.compile(...), PASSWORD = os.environ.get('X'), PUBLIC_KEY=ssh-ed25519, ${GITHUB_TOKEN}, git SHAs, UUIDs, sha256: digests and Go h1: hashes all come back byte-identical.
The eight off-by-default rules are off because cutting mail addresses, home paths and IP addresses breaks ordinary work with git log, stack traces and ip addr. public_ip6 is off for the same reason as public_ip: switch it on with enable = ["public_ip6"] when a transcript must not carry your server addresses. Entropy is off because it misses about a third of real secrets and invents false positives; the named rules do the real work.
Python 3.8 or newer, no packages to install. Python 3.11 adds tomllib, which the optional config file needs.
No Python at all? Each release carries a single-file binary for Linux x86_64, Linux aarch64 and macOS arm64, built from the same redact_output.py with its interpreter inside, so redact.toml, redact.secrets and the REDACT_* variables work the same:
curl -LO https://github.com/SilentAutomaton/redact-hook/releases/latest/download/redact-hook-linux-x86_64
curl -LO https://github.com/SilentAutomaton/redact-hook/releases/latest/download/redact-hook-linux-x86_64.sha256
sha256sum -c redact-hook-linux-x86_64.sha256
install -Dm755 redact-hook-linux-x86_64 ~/.claude/hooks/redact-hook
~/.claude/hooks/redact-hook --self-checkThen point the command in step 2 at ~/.claude/hooks/redact-hook (in full) and skip step 1. The first run unpacks the binary into your cache directory; later runs start from there.
git clone https://github.com/SilentAutomaton/redact-hook.git
cd redact-hook
python3 redact_output.py --self-checkThe self-check must print 0 failures before you install anything. Then:
install -Dm755 redact_output.py ~/.claude/hooks/redact_output.pyEdit ~/.claude/settings.json and add to the hooks section:
{
"hooks": {
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "/home/YOUR_USER/.claude/hooks/redact_output.py",
"timeout": 30
}
]
}
]
}
}Write the path in full. Claude Code does not expand ~ or $HOME in a hook command, and a hook that cannot start redacts nothing.
"matcher": ".*" runs the hook after every tool. To leave a tool alone, name it in REDACT_SKIP_TOOLS rather than narrowing the matcher — the default already skips WebFetch and WebSearch, whose output is public anyway, and Write, ToolSearch, ExitPlanMode and AskUserQuestion, whose output holds only what the model or you wrote.
Hooks are loaded at session start. Confirm the hook is live:
python3 ~/.claude/hooks/redact_output.py --list-rulesThen ask Claude to read a file holding a fake key and check that the value comes back as [REDACTED:...].
cd redact-hook && git pull
install -Dm755 redact_output.py ~/.claude/hooks/redact_output.pyRestart Claude Code afterwards. Your redact.toml is untouched.
Write redact.toml in $CLAUDE_CONFIG_DIR, or in ~/.claude if that variable is not set. Every field is optional.
# rules to switch off, by the name in [REDACTED:name]
disable = ["phone", "card"]
# "partial" keeps the head and tail of a whole token: sk_liv...D3fH
mask = "partial"
# rules to switch on
enable = ["entropy", "email", "home_path"]
# regexes that are never cut, whatever rule matched
allow = ["AKIAIOSFODNN7EXAMPLE", "example\\.com"]
# your own rules
[[rule]]
name = "internal_ticket"
pattern = "ACME-\\d{6}"
[[rule]]
name = "staff_id"
pattern = "EMP-[0-9]{5}"
replacement = "[staff]"A missing file is fine. Bad TOML, a bad regex or a rule with no pattern prints a warning on stderr and the rest keeps working — the hook never dies on a config error, because a dead hook redacts nothing.
A custom pattern with an unbounded repeat inside another, such as (a+)+ or (\w+\s?)*, is skipped with a warning too: on the wrong input it backtracks for minutes, and the hook sits in front of every tool result.
With mask = "partial", a token of 18 characters or more that a rule cuts whole comes back as its first 6 and last 4 characters, sk_liv...D3fH, so a log line still says which key it was. Rules that cut a value behind a key, such as wg_key or assignment, secret_file, and a custom rule with its own replacement keep the placeholder. A partial mask already in the text, written by this hook or another tool, is left alone on the next pass.
No pattern can tell an ordinary password from an ordinary word. For those, put the exact values in redact.secrets next to redact.toml, one per line:
install -m600 /dev/null ~/.claude/redact.secrets
$EDITOR ~/.claude/redact.secretsEvery value is matched literally, the longest first, and comes back as [REDACTED:secret_file]. Values shorter than 4 characters are skipped. The hook warns if the file is readable by other users and never prints a value.
These override the file, so a project can set its own rules through the env block of its .claude/settings.json without a second config file.
| Variable | Default | Description |
|---|---|---|
| REDACT_DISABLE | unset | Comma-separated rule names to switch off |
| REDACT_ENABLE | unset | Comma-separated rule names to switch on |
| REDACT_SKIP_TOOLS | WebFetch,WebSearch,Write,ToolSearch,ExitPlanMode,AskUserQuestion | Tool names to pass through untouched |
| REDACT_CONFIG | $CLAUDE_CONFIG_DIR/redact.toml, else ~/.claude/redact.toml | Config file path |
| REDACT_MASK | unset | partial is the same as mask = "partial" |
| REDACT_AGGRESSIVE | unset | 1 is an alias for REDACT_ENABLE=entropy |
entropy also switches on by itself for one result when the command prints a whole environment or a secrets file: env, printenv, set, export -p, declare -x, or cat/less/head/tail of .env*, .envrc, .netrc, .pgpass, credentials. A rule in disable stays off.
This directory contains a .claude-plugin/ with an install-redact-hook skill. To load it in Claude Code, add to ~/.claude/settings.json:
{
"extraKnownMarketplaces": {
"redact-hook": {
"source": {
"source": "directory",
"path": "/path/to/this/directory"
}
}
}
}Then install the plugin via /install-plugin in Claude Code and invoke with /install-redact-hook.
Read these before you trust the hook with anything.
python3 redact_output.py --self-check
python3 redact_output.py --list-rules--self-check runs both directions: one sample per rule that must be cut, and a list of ordinary output that must come back unchanged. It also fails if any default rule has no sample at all.
Rule shapes come from gitleaks and detect-secrets, including the split between named regex rules and an entropy pass.
Three ideas were borrowed from other Claude Code redactors: named rules and a value allowlist from redacted, type-preserving placeholders from cc-redact, and the PreToolUse/PostToolUse limitation above, which claude-code-redaction-hooks documents clearly. claude-code-redact takes the heavier road — a local proxy with round-trip un-redaction — if you want no coverage gaps and do not mind the dependencies.
MIT. See LICENSE.
| Back | FazBrowse Home | New Git URL |