Blocks Write, Edit and Bash calls that would put an API key, token or private key into a file or command.

Refuses a Write, Edit, MultiEdit or Bash call that would put an API key, token or private key into a file or a command. Claude gets the reason and is told to use an environment variable instead.
| Kind | Example shape |
|---|---|
| AWS access key | AKIA…, ASIA… + 16 chars |
| GitHub token | ghp_…, gho_…, github_pat_… |
| Anthropic / OpenAI key | sk-ant-…, sk-…, sk-proj-… |
| Slack, Stripe live, Google API key | xoxb-…, sk_live_…, AIza… |
| Private key | -----BEGIN … PRIVATE KEY----- |
| Hard-coded credential | api_key = "…", "password": "…" when the value looks random (entropy ≥ 3.5 bits/char) and isn't a placeholder |
The denial names the kind, the line, and the first four characters of the match. It never repeats the secret, so it stays out of the transcript.
Headless claude -p with the mod loaded, asked to write const awsKey = "AKIA…" to config.js. The write was refused, no file was created, and Claude relayed:
Secret Guard blocked this Write call: it contains AWS access key (AKIA…, 20 chars, line 1). Do not put secrets in files or commands. Read the value from an environment variable (a git-ignored .env file) instead, and ask the user to supply it. If the user confirms it is a false positive, add "secret-guard:allow" on that line.
Claude then asked before retrying with the allow marker.
One tool.call hook. It joins the text the call would write or run (command, content, new_string, each edits[].new_string), scans line by line, and returns { deny } on a match. Otherwise it calls next(e).
What claude plugin validate reports:
hooks: tool.call
calls: $.ui.toast
No file, process or network access.
Requires Claude Code 2.1.287 or later.
claude --plugin-dir ./mods/secret-guard # one session
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install secret-guard@awesome-claude-mods --scope user
Test it: npm test. The tests run with claude plugin test, not in a live session. The block was also checked once in a live headless session (see Demo).
gitleaks or push protection as the real gate.secret-guard:allow is skipped. Claude can add the marker itself, and the denial tells it to ask the user first, but nothing enforces that. This is a safety net, not a permission system..example, .sample or .template are not scanned.secret-guard:allow.None.
hooks/secret-guard.mjs 116 lines1// Secret Guard: refuses a tool call that would write a secret into a file or a command.
2//
3// tool.call (Write, Edit, MultiEdit, Bash): scan the text Claude is about to
4// write or run. On a match, deny with a reason Claude can act on. No UI, no
5// state: the only host call is a toast.
6//
7// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
8// spelled literally.
9
10const MAX_SCAN = 1_000_000; // ponytail: only the first 1 MB is scanned, raise it if a real file needs more
11const ALLOW_MARK = "secret-guard:allow";
12const TEMPLATE_FILE = /\.(example|sample|template)$/;
13const TOOLS = new Set(["Write", "Edit", "MultiEdit", "Bash"]);
14
15// Provider formats: precise enough to block without a second opinion.
16// Order matters: Anthropic keys also match the broader OpenAI shape.
17const RULES = [
18 ["AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
19 ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/],
20 ["Anthropic API key", /\bsk-ant-[A-Za-z0-9_-]{20,}/],
21 ["OpenAI API key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/],
22 ["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
23 ["Stripe live key", /\b[sr]k_live_[A-Za-z0-9]{20,}/],
24 ["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/],
25 ["private key", /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/],
26];
27
28// `api_key = "..."`, `"password": "..."`: only when the value looks random.
29const GENERIC = /(?:api[_-]?key|secret|token|passw(?:or)?d)\w*["']?\s*[:=]\s*["']([^"'\s]{16,})["']/i;
30const PLACEHOLDER = /example|placeholder|your[_-]|changeme|xxx|<[^>]+>|\$\{|process\.env|os\.environ/i;
31const MIN_ENTROPY = 3.5; // bits per character; english words sit near 2.5-3
32
33export function register(on) {
34 on("tool.call", async ($, e, next) => {
35 if (!TOOLS.has(e.tool) || TEMPLATE_FILE.test(String(e.file_path ?? ""))) {
36 return next(e);
37 }
38 const hits = scan(textOf(e));
39 if (hits.length === 0) {
40 return next(e);
41 }
42 try {
43 $.ui.toast(`Secret Guard blocked ${e.tool}: ${hits[0].label}`);
44 } catch {
45 // a toast is a courtesy; the denial below is what matters
46 }
47 const found = hits.map((h) => `${h.label} (${h.redacted}, line ${h.line})`).join("; ");
48 return {
49 deny:
50 `Secret Guard blocked this ${e.tool} call: it contains ${found}. ` +
51 `Do not put secrets in files or commands. Read the value from an environment variable ` +
52 `(a git-ignored .env file) instead, and ask the user to supply it. ` +
53 `If the user confirms it is a false positive, add "${ALLOW_MARK}" on that line.`,
54 };
55 }).catch(async () => ({
56 // fail closed: a skipped guard would let the call (and the secret) through
57 deny: "Secret Guard failed while checking this call, so it was not run. Do not retry it unless the user asks you to.",
58 }));
59}
60
61/** The new text a tool call would write or run. */
62function textOf(e) {
63 return [e.command, e.content, e.new_string, ...(Array.isArray(e.edits) ? e.edits.map((x) => x.new_string) : [])]
64 .filter((s) => typeof s === "string")
65 .join("\n")
66 .slice(0, MAX_SCAN);
67}
68
69/** Up to three { label, redacted, line } findings. */
70function scan(text) {
71 const hits = [];
72 const lines = text.split("\n");
73 for (let i = 0; i < lines.length && hits.length < 3; i += 1) {
74 const line = lines[i];
75 if (line.includes(ALLOW_MARK)) {
76 continue;
77 }
78 const hit = matchLine(line);
79 if (hit !== null) {
80 hits.push({ ...hit, line: i + 1 });
81 }
82 }
83 return hits;
84}
85
86function matchLine(line) {
87 for (const [label, re] of RULES) {
88 const m = re.exec(line);
89 if (m !== null) {
90 return { label, redacted: redact(m[0]) };
91 }
92 }
93 const g = GENERIC.exec(line);
94 if (g !== null && !PLACEHOLDER.test(line) && entropy(g[1]) >= MIN_ENTROPY) {
95 return { label: "hard-coded credential", redacted: redact(g[1]) };
96 }
97 return null;
98}
99
100// The reason goes back to the model and the transcript, so never echo the secret.
101function redact(value) {
102 return `${value.slice(0, 4)}…, ${value.length} chars`;
103}
104
105function entropy(s) {
106 const counts = new Map();
107 for (const ch of s) {
108 counts.set(ch, (counts.get(ch) ?? 0) + 1);
109 }
110 let h = 0;
111 for (const n of counts.values()) {
112 h -= (n / s.length) * Math.log2(n / s.length);
113 }
114 return h;
115}
116