DEV Community

Cover image for I Rotated the Same 5 API Keys Twice. Then I Wrote a Hook So I'd Never Have To Again.
Cor E
Cor E

Posted on

I Rotated the Same 5 API Keys Twice. Then I Wrote a Hook So I'd Never Have To Again.

Three times now, a Claude Code session has read a file full of live credentials straight into its own conversation transcript. Not maliciously. Not because I asked it to. It just ran cat or grep on something to "check a value," and the whole file, secrets included, ended up sitting in plaintext in a log I'll never fully control the retention of.

Each time cost me the same thing: rotating every credential in that file, one dashboard at a time, while whatever depended on them kept running on borrowed time.

The third time is what finally made me fix it properly instead of just being more careful.

The pattern, and why "be more careful" doesn't work

Here's roughly how each incident went. A .env file needed checking for some unrelated reason (line endings, whether a variable was set, what changed). Someone (me, or the agent on its own initiative) reached for the obvious tool: cat, or cat -A, or a plain grep PATTERN file. All of those print the matched line in full. If the matched line is ANTHROPIC_API_KEY=sk-ant-..., congratulations, that key is now part of the context window and the transcript both.

I wrote a memory note after the first one. Standard stuff: don't cat .env files, use cut -d= -f1 to list keys instead, use grep -c for presence checks. Sensible rule. Also, apparently, not sufficient, because it happened again on a .toml file I hadn't thought to add to the mental list, and then a third time when a completely different session used the Read tool instead of a shell command, which the rule hadn't accounted for at all because I was thinking about it as a "don't run these commands" problem instead of a "don't touch these files, by any means" problem.

That's the actual lesson buried in here: a memory note is advice the model re-derives every single time, probabilistically, from a description of the failure mode you happened to write down. It covers what you thought of. It does not cover the tool you didn't imagine using, or the file pattern you forgot to list, or the session three weeks from now that never loaded that particular memory into context at all.

Memory is a note. A hook is code.

I'd already learned this lesson once, for a different problem (an agent running as rootless kept leaving git-committed files with the wrong owner, breaking pushes downstream). The fix there was a PreToolUse hook, and it worked so well I'd half forgotten the underlying principle applied here too until the third leak made me sit down and actually apply it.

Claude Code hooks are shell commands the harness runs at defined lifecycle points, deterministically, every time, regardless of what the model "remembers" or feels like doing. A PreToolUse hook on a given tool runs before that tool executes and can return a decision that blocks it outright. Not a suggestion the model can rationalize past under pressure. An actual gate.

So instead of another memory note, I wrote a script.

Building the block

The shape is simple: a Python script that reads the pending tool call as JSON on stdin, checks whether it's trying to read a file that looks like it holds secrets, and if so, denies it before it runs.

The pattern list:

SECRET_PATH_PATTERNS = [
    r'(^|/)\.env(\.(?!example$|sample$|template$|dist$)[A-Za-z0-9_-]+)?$',
    r'(^|/)id_rsa(?!\.pub$)([.-][A-Za-z0-9_-]+)?$',
    r'(^|/)id_ed25519(?!\.pub$)([.-][A-Za-z0-9_-]+)?$',
    r'\.pem$',
    r'(^|/)credentials\.json$',
    r'(^|/)secrets\.[A-Za-z0-9]+$',
    r'\.key$',
    r'(^|/)frp\w*\.toml$',
    r'(^|/)\.bashrc$',
    r'(^|/)\.bash_profile$',
    r'(^|/)\.profile$',
    r'(^|/)\.zshrc$',
]
Enter fullscreen mode Exit fullscreen mode

For the Read tool, that's the whole check: does file_path match one of these, deny if so. For Bash, it's slightly more involved, because a shell command can touch a sensitive file in ways that are completely harmless (ls -la .env, chmod 600 .env, the cut/grep -c patterns from my original memory note) alongside ways that dump the raw contents. So the Bash check only fires when a secret-looking path shows up and the command uses something that would actually print file contents: cat, head, tail, sed, less, awk, or a bare grep without a -c flag.

The deny response itself is just JSON on stdout:

{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "Blocked: ... use cut -d= -f1 or grep -c instead."
    }
}
Enter fullscreen mode Exit fullscreen mode

Wired into settings.json, scoped to both matchers:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "python3 /path/to/block_secret_files.py" }]
      },
      {
        "matcher": "Read",
        "hooks": [{ "type": "command", "command": "python3 /path/to/block_secret_files.py" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Same script, two matchers. It reads tool_name out of the JSON payload to know which branch of logic to run.

The wrong turn I'd have shipped if I hadn't tested it

Here's the part worth actually reading, not skimming. I wrote the pattern list from memory of the incidents, felt confident about it, and pipe-tested it against a batch of synthetic cases before wiring it in anywhere.

Two of those synthetic cases failed immediately.

First: the actual file from one of the real incidents was frps.toml (an frp server config). My regex was frpc?\.toml$, "frp, optionally followed by c, then .toml." That matches frp.toml and frpc.toml. It does not match frps.toml, because I'd typed c? when I needed something that also covered s. The exact file from the exact incident that prompted the whole hook would have sailed straight through my own fix.

Second, and worse: I'd never added .bashrc to the pattern list at all, despite the first leak in this whole saga being grep -n "SOME_KEY" /rootless/.bashrc printing a live key from an exported environment variable. I'd mentally filed that incident under ".env files," wrote the hook thinking about ".env files," and just... didn't carry the earlier lesson forward into the new artifact meant to prevent all of this.

Both are exactly the kind of gap a hook is supposed to eliminate, and both would have survived if I'd wired the script in straight after writing it and called it done. The fix was two regex changes: frpc?\.toml$ became frp\w*\.toml$, and I added the four common shell rc files to the pattern list explicitly. Then I re-ran every test case, including the two that had failed, before touching any real config.

Prove it fires, not just that it should

Same discipline as always with hooks: a hook that silently no-ops gives you false confidence, which is worse than no hook, because now you think you're covered.

Three checks, in order:

  1. Pipe-test the raw script with synthetic JSON matching what the harness actually sends. I ran roughly fifteen cases: real secret files that should deny, safe operations on those same files that should pass, edge cases like id_rsa.pub (should always be readable, it's a public key) and .env.example (a placeholder file, not a real secret).
  2. Validate the settings.json with jq to confirm the hook is registered on the matcher I think it's registered on, not silently malformed.
  3. Fire it for real, in the live session, against the actual file from the actual incident. Not a simulation. The real Read tool, the real path, and I wanted to see the real denial message come back before I trusted any of this.

Step 3 is the one people skip, and it's the one that would have caught a config typo, a hook that's technically valid JSON but registered under the wrong matcher, or a settings file the harness isn't even watching. All three checks passed clean on the second try (the one with the fixed regex).

One thing I didn't expect

After the hook was live, I tried the officially-sanctioned safe pattern, cut -d= -f1 .env, expecting it to sail through since my hook explicitly allows it. It got blocked anyway, by a completely different layer, a built-in classifier the harness runs independently of anything I wrote. My hook had nothing to do with that denial. It's a good reminder that "I added a rule allowing this" and "this will definitely work" aren't the same claim when there's more than one system with an opinion running in the loop.

Where this generalizes

The underlying shape isn't really about secrets specifically. Any time you catch yourself writing a memory note that starts with "remember to check X before doing Y," ask whether X is something a regex, a status check, or a lookup could verify deterministically instead. If the answer's yes, and getting it wrong is expensive or hard to undo, that's a hook. A memory note is fine for things where "usually right" is an acceptable failure rate. Reading five live API keys into a log file is not one of those things, and it took me three tries to actually believe that about my own setup instead of just writing a slightly longer note each time.

— Cor, Skyblue Soft


AI-assisted draft or imaging, human-curated, reviewed and edited.

Top comments (0)