SLOPSHOPPER

secret-guard

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

newguardtoast
★ 1v0.1.0MITupdated 2026-10-04Justmalhar/awesome-claude-mods/mods/secret-guard
A shopper browsing a rack in a slop shop
README

Secret Guard

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.

What it catches

KindExample shape
AWS access keyAKIA…, ASIA… + 16 chars
GitHub tokenghp_…, gho_…, github_pat_…
Anthropic / OpenAI keysk-ant-…, sk-…, sk-proj-…
Slack, Stripe live, Google API keyxoxb-…, sk_live_…, AIza…
Private key-----BEGIN … PRIVATE KEY-----
Hard-coded credentialapi_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.

Demo

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.

How it works

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.

Run it

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

Notes / limitations

  • Pattern matching, not a secret scanner. It misses secrets split across lines or strings, base64-wrapped values, and formats it has no rule for. Use gitleaks or push protection as the real gate.
  • A line containing 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.
  • Files ending in .example, .sample or .template are not scanned.
  • It reads only what Claude writes or runs. A secret that is already in a file, or that a script prints, isn't seen.
  • Only the first 1 MB of a call is scanned, and the first three findings are reported.
  • The generic rule can flag a real-looking test fixture. Mark it secret-guard:allow.

Dependencies

None.

Source 1 files
hooks/secret-guard.mjs 116 lines
1// 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