SLOPSHOPPER

sensitive-file-guard

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

newguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sensitive-file-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by sensitive-file-guard: Sensitive File Guard blocked this Bash call: it touches a file matching ". ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Sensitive File Guard

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.

What this shows

A fail-closed tool.call guard that matches path strings only (no host calls), including a small quote-aware Bash tokenizer.

Demo

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.

How it works

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 $

Run it

claude --plugin-dir ./mods/sensitive-file-guard
claude plugin test mods/sensitive-file-guard

Notes / limitations

  • Not caught: shell indirection (f=.e; cat ${f}nv), $(cat .env), scripts that read the file, and MCP tools.
  • Bash matching is word-based, so echo credentials or a commit message mentioning .env is also blocked.
  • ~ and $HOME are not expanded; matching uses path segment names.
  • No drawing, so it does not use the AbovePrompt band.

Dependencies

None.

Source 1 files
hooks/sensitive-file-guard.mjs 77 lines
1// 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