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…

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
| Hook | What it does |
|---|---|
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. |
tool.call on Bash | Refuses a command line that embeds one, because commands are stored in the transcript as typed. $VAR references pass. |
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. |
ui.render on AbovePrompt | Counts what was redacted and refused. /secrets prints the tally. |
| Kind | Refuses writes | Example |
|---|---|---|
anthropic-key | yes | sk-ant-… |
openai-key | yes | sk-…, sk-proj-… |
github-token | yes | ghp_…, github_pat_… |
aws-access-key | yes | AKIA… |
slack-token | yes | xoxb-… |
stripe-key | yes | sk_live_…, rk_test_… |
google-api-key | yes | AIza… |
private-key | yes | -----BEGIN PRIVATE KEY----- blocks |
password-url | yes | postgres://user:password@host (only the password is redacted) |
jwt, bearer-token, env-secret, assignment | redact only | eyJ….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.
| Option | Default | What it does |
|---|---|---|
blockWrites | true | Refuse writes and commands that embed a high-confidence secret. |
redactResults | true | Redact tool results. |
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.
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.
hooks/register.tsx 190 lines1// 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}
190tests/register.test.ts 73 lines1import { 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})
73types/index.d.ts 21 lines1export 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}
21README.md 52 lines1# 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