FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

SilentAutomaton/redact-hook: PostToolUse hook for Claude Code that redacts secrets and PII from tool output before the model sees it. Pure regex, one file, no dependencies. · GitHub

Repository files navigation

redact-hook

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.).

Why

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.

How it works

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.

Rules

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
email 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.

Installation

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-check

Then 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.

1. Get the hook and check it

git clone https://github.com/SilentAutomaton/redact-hook.git
cd redact-hook
python3 redact_output.py --self-check

The self-check must print 0 failures before you install anything. Then:

install -Dm755 redact_output.py ~/.claude/hooks/redact_output.py

2. Add to settings.json

Edit ~/.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.

3. Restart Claude Code

Hooks are loaded at session start. Confirm the hook is live:

python3 ~/.claude/hooks/redact_output.py --list-rules

Then ask Claude to read a file holding a fake key and check that the value comes back as [REDACTED:...].

Updating

cd redact-hook && git pull
install -Dm755 redact_output.py ~/.claude/hooks/redact_output.py

Restart Claude Code afterwards. Your redact.toml is untouched.

Choosing the rules

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.

Partial masks

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.

Exact values

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.secrets

Every 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.

Environment variables

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.

Plugin installation (Claude Code skill)

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.

Limitations

Read these before you trust the hook with anything.

  • PostToolUse cannot stop egress. The tool has already run by the time the hook sees its output. This protects the model context, not the network. Only a PreToolUse hook can stop a tool from reaching out.
  • The local transcript keeps the raw value. toolUseResult in ~/.claude/projects/**/*.jsonl is written before redaction. The hook protects what goes to the API, not the file on your disk.
  • Tool input is never redacted — a secret you type in a prompt, or that Claude puts in a command line, is not covered.
  • @file mentions are expanded by the client and bypass hooks entirely.
  • Hooks load at session start; changes to settings.json need a restart.
  • MCP config injected via system-reminder bypasses PostToolUse; use ${VAR} substitution in .mcp.json instead.
  • The config file needs Python 3.11 or newer for tomllib. On an older interpreter the defaults run and a warning is printed.
  • Unknown secret formats with no prefix and no field name are missed unless entropy is on.

Testing

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.

Prior art

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.

Licence

MIT. See LICENSE.

About

PostToolUse hook for Claude Code that redacts secrets and PII from tool output before the model sees it. Pure regex, one file, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages


Back | FazBrowse Home | New Git URL