SLOPSHOPPER

secret-shield

Refuses Write, Edit and Bash calls that contain secret-looking values (API keys, tokens, private keys)

newguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-shield
› 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(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-shield ⎿ secret-shield: secret-shield is ON. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-shield

Refuses file writes and commands that contain secret-looking values, so credentials never land in your code or shell history.

What it does

  • Scans the content of Write, the new_string of Edit, and the command of Bash before they run.
  • Detects: Anthropic keys (sk-ant-...), OpenAI keys (sk-...), GitHub tokens (ghp_/gho_/ghs_, github_pat_), AWS access key ids (AKIA...), Slack tokens (xox[baprs]-), Google API keys (AIza...), and PEM private keys with a key body.
  • Ignores placeholders: values containing words like example, your, placeholder, redacted, dummy, fake, sample, changeme or xxxx, values with few distinct characters, and token-style values with no mix of letters and digits.
  • On a hit, the call is denied with a message to Claude (kind of secret, preview masked to the first 4 characters) and a toast for you. Claude is told to use an environment variable, a secret store or a placeholder.

Commands

CommandEffect
/secret-shield statusShow whether the shield is on.
/secret-shield offAllow secret-looking values for this session; status line shows secret-shield: OFF.
/secret-shield onTurn it back on.

Install

claude --plugin-dir /path/to/ModsTools/mods/secret-shield

Limits

  • Pattern-based: unknown token formats and secrets without a recognised prefix pass; false positives are possible (use /secret-shield off after asking).
  • Not covered: NotebookEdit, MultiEdit-style tools, and the old_string of Edit.
  • Bash commands are checked as text: it does not see through bash -c, $(...), eval, aliases or scripts. A seatbelt, not a security boundary.

Develop

claude plugin validate mods/secret-shield
claude plugin test mods/secret-shield   # 34 tests
Source 3 files
hooks/register.ts 57 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { findSecret } from './rules'
5
6const isOff = atom({ plugin: 'secret-shield', key: 'isOff' } as const, false)
7
8async function refusal($: EngineInterface, field: string, text: string): Promise<string | null> {
9  if (await read($, isOff)) return null
10  const found = findSecret(text)
11  if (found === null) return null
12  $.ui.toast(`secret-shield blocked a ${found.kind} (${found.preview})`)
13
14  return (
15    `secret-shield: refused, the ${field} contains what looks like a ${found.kind} (${found.preview}). ` +
16    'Never write secrets into files or commands: read them from an environment variable or a secret store, or use a placeholder. ' +
17    'If this is a false positive, ask the user; they can allow it for this session with /secret-shield off.'
18  )
19}
20
21export const register: Register = on => {
22  on('session.start', async ($, e, next) => {
23    await $.command.register({
24      name: 'secret-shield',
25      description: 'secret-shield on|off|status: refuse or allow secret-looking values in Write, Edit and Bash',
26    })
27
28    return next(e)
29  })
30
31  on('command.run', { command: 'secret-shield' }, async ($, e) => {
32    const arg = e.args.trim()
33    if (arg === 'off' || arg === 'on') {
34      await update($, isOff, () => arg === 'off')
35      $.ui.status(arg === 'off' ? 'secret-shield: OFF' : undefined)
36    }
37    const off = await read($, isOff)
38
39    return { text: `secret-shield is ${off ? 'OFF for this session' : 'ON'}.` }
40  })
41
42  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
43    const deny = await refusal($, 'file content', e.content)
44    return deny === null ? next(e) : { deny }
45  })
46
47  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
48    const deny = await refusal($, 'new text', e.new_string)
49    return deny === null ? next(e) : { deny }
50  })
51
52  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53    const deny = await refusal($, 'command', e.command)
54    return deny === null ? next(e) : { deny }
55  })
56}
57
hooks/rules.ts 39 lines
1export type Finding = { kind: string; preview: string }
2
3// Order matters: Anthropic before the generic sk- of OpenAI.
4const PATTERNS: { kind: string; re: RegExp; mixed?: boolean }[] = [
5  { kind: 'Anthropic API key', re: /(?<![\w-])sk-ant-[A-Za-z0-9_-]{20,}/g, mixed: true },
6  { kind: 'OpenAI API key', re: /(?<![\w-])sk-(?!ant-)[A-Za-z0-9_-]{20,}/g, mixed: true },
7  { kind: 'GitHub token', re: /(?<![\w-])gh[pos]_[A-Za-z0-9]{20,}/g, mixed: true },
8  { kind: 'GitHub token', re: /(?<![\w-])github_pat_[A-Za-z0-9_]{22,}/g, mixed: true },
9  { kind: 'AWS access key id', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![A-Za-z0-9])/g },
10  { kind: 'Slack token', re: /(?<![\w-])xox[baprs]-[A-Za-z0-9-]{10,}/g, mixed: true },
11  { kind: 'Google API key', re: /(?<![\w-])AIza[0-9A-Za-z_-]{35}/g },
12  { kind: 'private key', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----(?:\s|\\[rn])*[A-Za-z0-9+/=]{20,}/g },
13]
14
15const PLACEHOLDER_WORDS = /example|x{4}|your|placeholder|redacted|dummy|fake|sample|changeme/i
16
17// Placeholders: doc words, too few distinct characters, or (for random tokens) no digit/letter mix.
18function isPlaceholder(token: string, mixed: boolean): boolean {
19  const body = token.replace(/^(sk-ant-|sk-|gh[pos]_|github_pat_|AKIA|xox[baprs]-|AIza)/, '')
20  if (token.startsWith('-----BEGIN')) return false
21  if (PLACEHOLDER_WORDS.test(body)) return true
22  if (new Set(body.replace(/[-_]/g, '').toLowerCase()).size < 6) return true
23
24  return mixed && !(/[0-9]/.test(body) && /[A-Za-z]/.test(body))
25}
26
27export const mask = (secret: string) => `${secret.slice(0, 4)}****`
28
29/** The first secret-looking value in the text, or null when it is clean. */
30export function findSecret(text: string): Finding | null {
31  for (const { kind, re, mixed } of PATTERNS) {
32    for (const m of text.matchAll(re)) {
33      if (!isPlaceholder(m[0], mixed === true)) return { kind, preview: mask(m[0]) }
34    }
35  }
36
37  return null
38}
39
types/index.d.ts 8 lines
1export type ShieldSwitch = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'secret-shield': { isOff: ShieldSwitch }
6  }
7}
8