Blocks Read, Write, Edit, Grep, Glob and Bash calls that touch .env files, private keys and credential stores.

Refuses Read, Write, Edit, MultiEdit, Grep, Glob and Bash calls that touch .env files, private keys or credential stores, and tells Claude to ask you for the one non-secret value it needs.
A fail-closed tool.call guard that matches path strings only (no host calls), including a small quote-aware Bash tokenizer.
claude -p with a fabricated .env (FOO=bar), asking Claude to read it:
Sensitive File Guard blocked this Read call: it touches a file matching ".env", which may hold secrets. Do not read, copy or modify it.
One tool.call hook on the tools above. Paths come from file_path, path (and pattern for Glob). For Bash, command is split into words (quotes stripped, split on whitespace and <>|;&()=), and every word is tested. Matched patterns: .env and .env.* (except .example, .sample, .template, .dist), *.pem, *.key, *.p12, *.pfx, id_rsa*/id_ed25519*/id_ecdsa* (not .pub), anything under .ssh/, .aws/, .gnupg/ (not .pub), .kube/config, .npmrc, .netrc, .pypirc, .docker/config.json, credentials, credentials.json, Keychains. The deny reason names the pattern, never file contents. If the hook itself throws, the call is denied (.catch).
hooks: tool.call
calls: nothing on $
claude --plugin-dir ./mods/sensitive-file-guard
claude plugin test mods/sensitive-file-guard
f=.e; cat ${f}nv), $(cat .env), scripts that read the file, and MCP tools.echo credentials or a commit message mentioning .env is also blocked.~ and $HOME are not expanded; matching uses path segment names.AbovePrompt band.None.
hooks/sensitive-file-guard.mjs 77 lines1// Sensitive File Guard: refuses tool calls that touch .env files, private keys
2// and credential stores. Pure path-string matching, no host calls.
3//
4// The host reads `on(...)` from source, so it is spelled literally.
5
6const TOOLS = new Set(["Read", "Write", "Edit", "MultiEdit", "Grep", "Glob", "Bash"]);
7const DIRS = [".ssh", ".aws", ".gnupg"]; // anything under these
8const NAMES = [".npmrc", ".netrc", ".pypirc", "credentials", "credentials.json"];
9
10/** The matched pattern's name, or null. `p` is one path-like string. */
11function sensitive(p) {
12 // ponytail: `~` and `$HOME` are not expanded; matching is on segment names, so they need no expansion
13 const segs = p.replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean);
14 const base = segs[segs.length - 1] ?? "";
15 const dir = DIRS.find((d) => segs.includes(d));
16 if (dir && !base.endsWith(".pub")) return `${dir}/`; // public keys are fine
17 if (base === ".env" || (base.startsWith(".env.") && !/\.(example|sample|template|dist)$/.test(base))) return ".env";
18 if (/\.(pem|key|p12|pfx)$/.test(base)) return `*.${base.split(".").pop()}`;
19 if (/^id_(rsa|ed25519|ecdsa)/.test(base) && !base.endsWith(".pub")) return "private SSH key";
20 if (NAMES.includes(base)) return base;
21 if (base === "config" && segs.includes(".kube")) return ".kube/config";
22 if (base === "config.json" && segs.includes(".docker")) return ".docker/config.json";
23 if (segs.some((s) => s === "keychains" || /\.keychain(-db)?$/.test(s))) return "Keychains";
24 return null;
25}
26
27/** Quote-aware split on whitespace and shell operators; quotes are stripped. */
28function tokenize(cmd) {
29 const out = [];
30 let cur = "";
31 let q = null;
32 for (let i = 0; i < cmd.length; i += 1) {
33 const c = cmd[i];
34 if (q) {
35 if (c === q) q = null;
36 else cur += c;
37 } else if (c === '"' || c === "'") q = c;
38 else if (c === "\\" && i + 1 < cmd.length) cur += cmd[++i];
39 else if (/[\s<>|;&()=]/.test(c)) {
40 if (cur) out.push(cur);
41 cur = "";
42 } else cur += c;
43 }
44 if (cur) out.push(cur);
45 return out;
46}
47
48// ponytail: any word that looks like a sensitive name blocks, even in `echo credentials`
49// or a commit message. Shell indirection, `$(cat .env)`, scripts and MCP tools are not caught.
50function find(e) {
51 const paths = [e.file_path, e.path];
52 if (e.tool === "Glob") paths.push(e.pattern);
53 if (e.tool === "Bash") paths.push(...tokenize(String(e.command ?? "")).flatMap((t) => [t, ...t.split(/\s+/)]));
54 for (const p of paths) {
55 const hit = typeof p === "string" ? sensitive(p) : null;
56 if (hit) return hit;
57 }
58 return null;
59}
60
61export function register(on) {
62 on("tool.call", async ($, e, next) => {
63 if (!TOOLS.has(e.tool)) return next(e);
64 const hit = find(e);
65 if (hit === null) return next(e);
66 return {
67 deny:
68 `Sensitive File Guard blocked this ${e.tool} call: it touches a file matching "${hit}", which may hold secrets. ` +
69 `Do not read, copy or modify it. If you need a value from it, ask the user to paste only the specific ` +
70 `non-secret value, or work from a .env.example file instead.`,
71 };
72 }).catch(async () => ({
73 // fail closed: a skipped guard would let the call through
74 deny: "Sensitive File Guard failed while checking this call, so it was not run. Do not retry it unless the user asks you to.",
75 }));
76}
77