SLOPSHOPPER

secret-shield

Keeps secrets out of the transcript and out of the repo: redacts API keys, tokens, private keys and password URLs from tool results before Claude reads them…

neworiginalbandguardcommandtoast
v0.1.0MITupdated 2026-10-06ryx2/slopshopper/mods/secret-shield
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 ╭────────────────────────────────────────────╮ │ secret-shield │ ⏺ Read(src/auth.ts) │ Redacted a stripe-key from a Bash result │ ⎿ Read 6 lines │ before Claude read it │ ⏺ 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 › /secrets ⎿ secret-shield: 1× redacted stripe-key ⛨ secret-shield 1 redacted latest: stripe-key in a Bash result h: Hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⛨ secret-shield 1 redacted latest: stripe-key in a Bash result h: Hide
README

secret-shield

Keeps secrets out of the transcript and out of the repo.

 ⛨ secret-shield  2 redacted  1 refused  latest: stripe-key in a Bash result   h: Hide
HookWhat it does
tool.call on Write and EditRefuses new text that contains a high-confidence secret, and tells Claude to read it from the environment instead.
tool.call on BashRefuses a command line that embeds one, because commands are stored in the transcript as typed. $VAR references pass.
session.append for tool resultsReplaces secrets in a tool's result with [redacted <kind> by secret-shield] before Claude reads it and before the transcript file stores it. A cat .env still shows every variable name.
ui.render on AbovePromptCounts what was redacted and refused. /secrets prints the tally.

What it recognizes

KindRefuses writesExample
anthropic-keyyessk-ant-…
openai-keyyessk-…, sk-proj-…
github-tokenyesghp_…, github_pat_…
aws-access-keyyesAKIA…
slack-tokenyesxoxb-…
stripe-keyyessk_live_…, rk_test_…
google-api-keyyesAIza…
private-keyyes-----BEGIN PRIVATE KEY----- blocks
password-urlyespostgres://user:password@host (only the password is redacted)
jwt, bearer-token, env-secret, assignmentredact onlyeyJ….eyJ….…, Bearer …, FOO_SECRET=…, api_key: "…"

The lower-confidence kinds only redact results; they never refuse a write, so a test fixture with a fake token still lands.

Options

OptionDefaultWhat it does
blockWritestrueRefuse writes and commands that embed a high-confidence secret.
redactResultstrueRedact tool results.

What it does not do

It does not read your prompt: if you paste a secret, Claude still sees it. It does not scan files Claude reads with the Read tool's own structured output beyond the text blocks a result carries. Patterns are patterns: a long random string that happens to match is redacted, and an unusual key format is not. Rotate any secret that reached a transcript.

Install

claude plugin marketplace add ryx2/slopshopper
claude plugin install secret-shield@slopshopper

Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass.

Source 4 files
hooks/register.tsx 190 lines
1// secret-shield: keeps secrets out of the transcript and out of the repo.
2//
3// tool.call (Write, Edit): refuses new text that contains a high-confidence
4//   secret (an API key, a token, a private key, a password in a URL).
5// tool.call (Bash): refuses a command line that embeds one, since the
6//   command is stored in the transcript as typed.
7// session.append (door tool-result): redacts secrets from a tool's result
8//   before the model reads it and before the transcript stores it, so a
9//   `cat .env` shows the variable names and not their values.
10// ui.render (AbovePrompt): a count of what was redacted and refused.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { ShieldEvent } from '../types'
16
17const MAX_EVENTS = 200
18
19const events = atom({ plugin: 'secret-shield', key: 'events' } as const, [])
20const turnCount = atom({ plugin: 'secret-shield', key: 'turnCount' } as const, 0)
21const isHidden = atom({ plugin: 'secret-shield', key: 'isHidden' } as const, false)
22
23type Pattern = { kind: string; re: RegExp; high: boolean }
24
25// High-confidence patterns refuse writes; every pattern redacts results.
26const PATTERNS: Pattern[] = [
27  { kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{24,}/g, high: true },
28  { kind: 'openai-key', re: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/g, high: true },
29  { kind: 'github-token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})/g, high: true },
30  { kind: 'aws-access-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, high: true },
31  { kind: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{20,}/g, high: true },
32  { kind: 'stripe-key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/g, high: true },
33  { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g, high: true },
34  { kind: 'private-key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g, high: true },
35  { kind: 'password-url', re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqps?|mssql|ftp|smtp):\/\/[^\s:@/]+:([^\s@/]{4,})@/g, high: true },
36  { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}/g, high: false },
37  { kind: 'bearer-token', re: /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{24,}/g, high: false },
38  { kind: 'env-secret', re: /^(?:export\s+)?([A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY|CLIENT_SECRET|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*['"]?([^'"\s#]{8,})['"]?/gm, high: false },
39  { kind: 'assignment', re: /\b(?:api[_-]?key|secret|password|passwd|auth[_-]?token|access[_-]?token)\b\s*[:=]\s*['"]([^'"\s]{16,})['"]/gi, high: false },
40]
41
42export const register: Register = (on, options) => {
43  const blockWrites = options.blockWrites !== false
44  const redactResults = options.redactResults !== false
45
46  on('session.start', async ($, e, next) => {
47    try {
48      await $.command.register({ name: 'secrets', description: 'What secret-shield redacted and refused this session' })
49    } catch {
50      // name taken; the band still works
51    }
52    return next(e)
53  })
54
55  on('turn.start', async ($, e, next) => {
56    await update($, turnCount, n => n + 1)
57    return next(e)
58  })
59
60  on('command.run', { command: 'secrets' }, async $ => {
61    const list = await read($, events)
62    if (list.length === 0) return { text: 'secret-shield: nothing redacted or refused this session.' }
63    const counts = new Map<string, number>()
64    for (const ev of list) counts.set(`${ev.action} ${ev.kind}`, (counts.get(`${ev.action} ${ev.kind}`) ?? 0) + 1)
65    return { text: [...counts.entries()].map(([k, n]) => `${n}× ${k}`).join('\n') }
66  })
67
68  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
69    if (!blockWrites) return next(e)
70    const text = e.tool === 'Write' ? String(e.content ?? '') : String(e.new_string ?? '')
71    const hit = firstSecret(text, true)
72    if (hit === null) return next(e)
73    await record($, { action: 'refused', kind: hit.kind, tool: e.tool, turn: await read($, turnCount) })
74    $.ui.toast(`Refused a ${e.tool} to ${shortPath(String(e.file_path ?? ''))}: it contains a ${hit.kind}`)
75    return {
76      deny: `secret-shield refused this ${e.tool} to ${shortPath(String(e.file_path ?? ''))}: line ${hit.line} contains what looks like a ${hit.kind}. Never write a credential into a file. Read it from an environment variable or a secrets manager, and if the user pasted it, ask them to rotate it.`,
77    }
78  })
79
80  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
81    if (!blockWrites) return next(e)
82    const hit = firstSecret(String(e.command ?? ''), true)
83    if (hit === null) return next(e)
84    await record($, { action: 'refused', kind: hit.kind, tool: 'Bash', turn: await read($, turnCount) })
85    $.ui.toast(`Refused a Bash command that embeds a ${hit.kind}`)
86    return {
87      deny: `secret-shield refused this command: it embeds what looks like a ${hit.kind}, and commands are kept in the transcript as typed. Pass the value through an environment variable (for example "$API_KEY") instead of pasting it.`,
88    }
89  })
90
91  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
92    if (!redactResults) return next(e)
93    let found: string | null = null
94    const content = e.message.content.map(block => {
95      if (block.type === 'text' && typeof block.text === 'string') {
96        const r = redact(block.text)
97        if (r.kind) found = found ?? r.kind
98        return r.kind ? { ...block, text: r.text } : block
99      }
100      if (block.type === 'tool_result') {
101        if (typeof block.content === 'string') {
102          const r = redact(block.content)
103          if (r.kind) found = found ?? r.kind
104          return r.kind ? { ...block, content: r.text } : block
105        }
106        if (Array.isArray(block.content)) {
107          let changed = false
108          const inner = (block.content as { type: string; text?: string }[]).map(part => {
109            if (part.type !== 'text' || typeof part.text !== 'string') return part
110            const r = redact(part.text)
111            if (!r.kind) return part
112            changed = true
113            found = found ?? r.kind
114            return { ...part, text: r.text }
115          })
116          return changed ? { ...block, content: inner } : block
117        }
118      }
119      return block
120    })
121    if (found === null) return next(e)
122    const tool = e.origin.kind === 'tool' ? String(e.origin.tool) : 'tool'
123    await record($, { action: 'redacted', kind: found, tool, turn: await read($, turnCount) })
124    $.ui.toast(`Redacted a ${found} from a ${tool} result before Claude read it`)
125    return next({ ...e, message: { ...e.message, content } })
126  })
127
128  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129    const list = await read($, events)
130    if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
131    const { Box, Text, Button } = $.ui.resolve(e)
132    const redacted = list.filter(x => x.action === 'redacted').length
133    const refused = list.filter(x => x.action === 'refused').length
134    const last = list[list.length - 1]!
135    return (
136      <Box flexDirection="row" columnGap={2} paddingX={1}>
137        <Text color="green" bold>
138          ⛨ secret-shield
139        </Text>
140        {redacted > 0 && <Text>{redacted} redacted</Text>}
141        {refused > 0 && <Text color="yellow">{refused} refused</Text>}
142        <Text dimColor>
143          latest: {last.kind} in a {last.tool} {last.action === 'redacted' ? 'result' : 'call'}
144        </Text>
145        <Button key="hide" label="Hide" hotkey="h" plain dimColor onPress={() => update($, isHidden, () => true)} />
146      </Box>
147    )
148  })
149}
150
151async function record($: EngineInterface, ev: ShieldEvent): Promise<void> {
152  await update($, events, list => [...list, ev].slice(-MAX_EVENTS))
153}
154
155/** The first secret in `text`; only high-confidence kinds when `highOnly`. */
156export function firstSecret(text: string, highOnly: boolean): { kind: string; line: number } | null {
157  for (const p of PATTERNS) {
158    if (highOnly && !p.high) continue
159    p.re.lastIndex = 0
160    const m = p.re.exec(text)
161    if (m) return { kind: p.kind, line: text.slice(0, m.index).split('\n').length }
162  }
163  return null
164}
165
166/** `text` with every secret replaced by a marker, and the first kind found. */
167export function redact(text: string): { text: string; kind: string | null } {
168  let kind: string | null = null
169  let out = text
170  for (const p of PATTERNS) {
171    p.re.lastIndex = 0
172    if (!p.re.test(out)) continue
173    kind = kind ?? p.kind
174    p.re.lastIndex = 0
175    out = out.replace(p.re, (whole: string, ...groups: unknown[]) => {
176      // a value an earlier pattern already replaced is left as its marker
177      if (p.kind === 'env-secret') return String(groups[1]).startsWith('[redacted') ? whole : `${String(groups[0])}=[redacted ${p.kind} by secret-shield]`
178      if (p.kind === 'password-url') return whole.replace(String(groups[0]), '[redacted-password]')
179      if (p.kind === 'assignment') return String(groups[0]).startsWith('[redacted') ? whole : whole.replace(String(groups[0]), '[redacted by secret-shield]')
180      return `[redacted ${p.kind} by secret-shield]`
181    })
182  }
183  return { text: out, kind }
184}
185
186export function shortPath(file: string): string {
187  const parts = file.split('/').filter(Boolean)
188  return parts.length <= 2 ? parts.join('/') : parts.slice(-2).join('/')
189}
190
tests/register.test.ts 73 lines
1import { describe, expect, test } from 'claude-code/testing'
2import { firstSecret, redact } from '../hooks/register'
3
4// `claude plugin test` has nothing beneath the mods to store a session row, so
5// the session.append hook is covered through the pure functions it calls;
6// the hook itself is three lines of mapping over the row's blocks.
7
8const KEY = 'sk-ant-api03-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0'
9
10const BAND = {
11  plugin: 'secret-shield',
12  component: 'AbovePrompt',
13  requestId: 'band',
14  viewport: { columns: 120, rows: 40 },
15  props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 100, scroll: { offset: 0, bodyRows: 8 }, view: {} },
16} as const
17
18describe('register', () => {
19  test('refuses a Write that contains an API key, and counts it in the band', async ($, on) => {
20    on('tool.call', () => ({ result: 'ok' }))
21    on('ui.toast', () => ({ value: undefined }))
22    on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
23    const out = await $.tool.call({ tool: 'Write', file_path: '/work/app/config.ts', content: `export const key = "${KEY}"\n` })
24    expect(out.deny).toContain('anthropic-key')
25    const clean = await $.tool.call({ tool: 'Write', file_path: '/work/app/config.ts', content: 'export const key = process.env.ANTHROPIC_API_KEY\n' })
26    expect(clean.deny).toBeUndefined()
27
28    for (const surface of ['terminal', 'desktop'] as const) {
29      const ui = await $.ui.mount({ ...BAND, surface })
30      expect(await ui.find({ type: 'Text', text: /1 refused/ })).toBeDefined()
31      await ui.unmount()
32    }
33    const answer = await $.command.run({ command: 'secrets', args: '' })
34    expect(answer.text).toContain('1× refused anthropic-key')
35  })
36
37  test('refuses a Bash command that embeds a token', async ($, on) => {
38    on('tool.call', () => ({ result: 'ok' }))
39    on('ui.toast', () => ({ value: undefined }))
40    const out = await $.tool.call({ tool: 'Bash', command: `curl -H "Authorization: Bearer ghp_${'x'.repeat(36)}" https://api.github.com/user` })
41    expect(out.deny).toContain('github-token')
42    const ok = await $.tool.call({ tool: 'Bash', command: 'curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user' })
43    expect(ok.deny).toBeUndefined()
44  })
45
46  test('redact() replaces secrets in a tool result and keeps the rest', () => {
47    // assembled at run time so no secret-shaped literal sits in the repository
48    const stripe = ['sk', 'live', '51H' + 'x'.repeat(24)].join('_')
49    const env = `DATABASE_URL=postgres://app:hunter2secret@db.internal:5432/app\nSTRIPE_SECRET_KEY=${stripe}\nPORT=3000\n`
50    const r = redact(env)
51    expect(r.kind).toBe('stripe-key')
52    expect(r.text).not.toContain('hunter2secret')
53    expect(r.text).not.toContain('sk_live_')
54    expect(r.text).toContain('PORT=3000')
55    expect(r.text).toContain('DATABASE_URL=postgres://app:[redacted-password]@db.internal:5432/app')
56    expect(r.text).toContain('[redacted stripe-key by secret-shield]')
57  })
58
59  test('redact() leaves ordinary output alone', () => {
60    const r = redact('src/auth.ts\nsrc/api.ts\nconst token = await issue(claims.sub)\n')
61    expect(r.kind).toBeNull()
62    expect(r.text).toBe('src/auth.ts\nsrc/api.ts\nconst token = await issue(claims.sub)\n')
63  })
64
65  test('firstSecret() reports the kind and line, and honors highOnly', () => {
66    expect(firstSecret(`a\nb\n${KEY}\n`, true)).toEqual({ kind: 'anthropic-key', line: 3 })
67    expect(firstSecret('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----', true)?.kind).toBe('private-key')
68    expect(firstSecret('API_TOKEN=abcdefghijklmnop', true)).toBeNull()
69    expect(firstSecret('API_TOKEN=abcdefghijklmnop', false)?.kind).toBe('env-secret')
70    expect(firstSecret('nothing to see', false)).toBeNull()
71  })
72})
73
types/index.d.ts 21 lines
1export type ShieldEvent = {
2  /** 'redacted' for a tool result, 'refused' for a write or command. */
3  action: 'redacted' | 'refused'
4  /** The secret's kind: anthropic-key, github-token, private-key, ... */
5  kind: string
6  /** The tool that produced or carried it. */
7  tool: string
8  /** The turn it happened in. */
9  turn: number
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'secret-shield': {
15      events: ShieldEvent[]
16      turnCount: number
17      isHidden: boolean
18    }
19  }
20}
21
README.md 52 lines
1# secret-shield
2
3Keeps secrets out of the transcript and out of the repo.
4
5```text
6 ⛨ secret-shield  2 redacted  1 refused  latest: stripe-key in a Bash result   h: Hide
7```
8
9| Hook | What it does |
10| --- | --- |
11| `tool.call` on `Write` and `Edit` | Refuses new text that contains a high-confidence secret, and tells Claude to read it from the environment instead. |
12| `tool.call` on `Bash` | Refuses a command line that embeds one, because commands are stored in the transcript as typed. `$VAR` references pass. |
13| `session.append` for tool results | Replaces secrets in a tool's result with `[redacted <kind> by secret-shield]` before Claude reads it and before the transcript file stores it. A `cat .env` still shows every variable name. |
14| `ui.render` on `AbovePrompt` | Counts what was redacted and refused. `/secrets` prints the tally. |
15
16## What it recognizes
17
18| Kind | Refuses writes | Example |
19| --- | :-: | --- |
20| `anthropic-key` | yes | `sk-ant-…` |
21| `openai-key` | yes | `sk-…`, `sk-proj-…` |
22| `github-token` | yes | `ghp_…`, `github_pat_…` |
23| `aws-access-key` | yes | `AKIA…` |
24| `slack-token` | yes | `xoxb-…` |
25| `stripe-key` | yes | `sk_live_…`, `rk_test_…` |
26| `google-api-key` | yes | `AIza…` |
27| `private-key` | yes | `-----BEGIN PRIVATE KEY-----` blocks |
28| `password-url` | yes | `postgres://user:password@host` (only the password is redacted) |
29| `jwt`, `bearer-token`, `env-secret`, `assignment` | redact only | `eyJ….eyJ….…`, `Bearer …`, `FOO_SECRET=…`, `api_key: "…"` |
30
31The lower-confidence kinds only redact results; they never refuse a write, so a test fixture with a fake token still lands.
32
33## Options
34
35| Option | Default | What it does |
36| --- | --- | --- |
37| `blockWrites` | `true` | Refuse writes and commands that embed a high-confidence secret. |
38| `redactResults` | `true` | Redact tool results. |
39
40## What it does not do
41
42It does not read your prompt: if you paste a secret, Claude still sees it. It does not scan files Claude reads with the `Read` tool's own structured output beyond the text blocks a result carries. Patterns are patterns: a long random string that happens to match is redacted, and an unusual key format is not. Rotate any secret that reached a transcript.
43
44## Install
45
46```bash
47claude plugin marketplace add ryx2/slopshopper
48claude plugin install secret-shield@slopshopper
49```
50
51Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass.
52