Keeps API keys, tokens and passwords out of the transcript, tool output, files, commits, PRs and issues.

A Claude Code mod that keeps API keys, tokens and passwords out of the agent's reach — enforced by code on your machine, not by instructions in a prompt.
It watches every tool call Claude makes and every row written to the conversation, and refuses (or flags) anything that would print, store, commit, push or send a secret.
📖 See how it decides — interactive flow diagram
⚠️ First draft — read this before relying on it
This is a first attempt at making secret handling local and rigorous by code, not by prompt. Instructions in a
CLAUDE.mdask the model to behave; a hook runs on every tool call whether the model remembers the instruction or not. This mod moves those rules into code that runs on this machine.It is best effort, not a guarantee:
- The checks are pattern lists (file paths, command shapes, token formats). A command written in a form they don't anticipate — an alias, a script file,
eval, unusual quoting — can get past them.- Secret values are recognised only for environment variables whose names match
secretNames, and only those present when the session started.- Secret-shaped strings are recognised only for the token formats listed below.
- Nothing it does can remove a value that already reached a transcript, a commit or a remote. If a secret leaks, rotate it.
Treat it as a seatbelt alongside careful habits, not as a replacement for them. Bypass reports and ideas are welcome as issues.
An agent with a shell can leak a secret in many small ways: cat ~/.bashrc to "check a variable", echo $API_KEY to debug, ${TOKEN:-default} in a test, a key pasted into a config file, a commit, a PR body. Each lands in the transcript, and once there it cannot be scrubbed — the only fix is rotating the key.
The usual defence is a rule in the prompt. Prompts are advice: they can be forgotten, summarised away, or out-argued. secret-guard turns the same rules into Claude Code function hooks that run on every call, locally, before anything executes.
| Layer | When | What happens |
|---|---|---|
| Know | Session start | Reads the environment once and keeps the values of variables whose names look secret (…_KEY, …_TOKEN, SECRET, PASSWORD, AUTH, …). In memory only — never displayed, logged or stored. |
| Guard | Before every tool call | Inspects what the call would do. A risk → block (or flag in warn mode) with the reason and a safe alternative. No risk → it runs. |
| Scrub | Before every row is stored | Replaces known values and token-shaped strings in tool output, replies and prompts with [redacted:NAME]. |
| Show | Always | A banner above the prompt, a status-line entry, a toast per event, a note on each blocked call, and /guard. |
| Rule | Stops | Do this instead | ||
|---|---|---|---|---|
secret-file-read | cat / grep / sed / cp / … or Read on .bashrc, .zshrc, .profile, .env*, ~/.aws/*, .netrc, secrets/, credentials/, .ssh/, .gnupg/, .psst/ | `grep -nE '^export NAME=' FILE \ | sed -E 's/=.*/=<redacted>/'` | |
secret-file-write | Write over a secret file | Hand the user the line with a <placeholder> | ||
env-dump | env, printenv, set, export -p printing values | `env \ | cut -d= -f1 \ | sort` |
printenv-secret | printenv API_KEY to the screen | `printenv API_KEY \ | sha256sum \ | cut -c1-12` |
echo-secret | echo $API_KEY | [ -n "$API_KEY" ] && echo set, ${API_KEY:+set}, ${#API_KEY} | ||
default-expansion | ${API_KEY:-x}, ${API_KEY:=x} (they expand to the value) | ${API_KEY:+set} | ||
secret-on-argv | curl -H "Authorization: Bearer $TOKEN" (visible in ps and logs) | `printenv TOKEN \ | cmd --stdin, cmd <<< "$TOKEN"` | |
git-add-secret-file | Staging a secret file, explicitly or through git add -A / . | — | ||
commit:* | A commit whose staged diff adds a known value or a token shape | — | ||
push:* | A push of commits (not yet on any remote) that add one | — | ||
gh:* | gh pr / issue / release / gist text or body files holding one | — | ||
secret-value | A real value anywhere in a command, a file write, or any tool's input (MCP tools included) | Refer to the variable by name | ||
secret-shape | A string that looks like a token, even an unknown one | Use a <placeholder> |
Token shapes recognised: AWS access keys · GitHub tokens and fine-grained PATs · Anthropic keys · OpenAI-style keys · Slack tokens · Google API keys · Ex Libris (Alma) API keys · PEM private-key headers · JWTs · password = "…"-style hard-coded credentials.
When Claude tries echo "$ALMA_API_KEY", the call never runs. Claude receives:
secret-guard blocked this Bash call because it could expose a secret:
- [echo-secret] echo would print the value of ALMA_API_KEY
(instead: show presence only: [ -n "$NAME" ] && echo set || echo unset)
Do not retry it in another form. If the user needs the value checked, let them do it in their own terminal.
and you see a toast, a note on the call, and the banner above the prompt:
╭──────────────────────────────────────────────────────────────────╮
│ 🛡 SECRET GUARD ON mode BLOCK [–] │
│ watching: secret files · env · echo/argv · writes · outbound … │
│ 12 secret vars known · 1 blocked · 0 flagged · 0 redacted │
│ last: blocked Bash — echo would print the value of ALMA_API_KEY │
╰──────────────────────────────────────────────────────────────────╯
In a Claude Code terminal session:
/plugin install secret-guard --marketplace hagaybar/claude-secret-guard
Answer y to add the marketplace, then choose the user scope so it loads in every session. Requires a Claude Code build with function-hook mods; tested on 2.1.291.
To work on it locally instead:
git clone https://github.com/hagaybar/claude-secret-guard ~/claude-mods/secret-guard
claude plugin marketplace add ~/claude-mods/secret-guard
claude plugin install secret-guard@hagay-mods --scope user
Edits to that folder take effect with /reload-plugins.
| Command | Does |
|---|---|
/guard | Status: mode, secret variables known (count only), this session's blocks / flags / redactions |
/guard log | The last 20 events across sessions |
/guard block · /guard warn | Refuse risky calls, or let them run and only declare them |
/guard full · /guard compact · /guard off | Banner style |
All in /config (or /plugin configure secret-guard@hagay-mods):
| Setting | Default | Meaning | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
mode | block | block refuses a risky call; warn runs it and declares it | ||||||||||
banner | full | full, compact or off | ||||||||||
secretNames | `(^\ | _)(KEY\ | APIKEY\ | TOKEN\ | SECRET\ | PASSWORD\ | PASSWD\ | PASSPHRASE\ | CREDENTIALS?\ | AUTH)(_\ | $)` | Regex (case-insensitive) for variable names whose values are secrets |
extraForbiddenPaths | (empty) | Comma-separated globs added to the built-in list, e.g. config/prod.yaml,**/*.pem | ||||||||||
allowPaths | .env.example,.env.sample,.env.template | Globs never treated as secret files | ||||||||||
redactOutput | true | Scrub values and token shapes from stored rows | ||||||||||
guardGit | true | Scan staged changes, outgoing commits and gh bodies | ||||||||||
loadEnvValues | true | Learn real values at session start (in memory only) |
Three hooks do the work (see the flow diagram):
session.start — runs env once through Claude Code's process API, keeps matching values in a module variable, registers /guard.tool.call — routes by tool: Bash gets the shell rules plus a scan of the command, and for git add / commit / push / gh it runs git status, git diff --cached or git log … --not --remotes and scans the added lines. Read / Write / Edit check the path and the new content. Every other tool (MCP, WebFetch, Agent, …) has all its text inputs scanned. The hook has a .catch that fails closed in block mode: if the check itself crashes, the call is held back (/guard warn is the escape hatch).session.append — rewrites text and tool-result blocks before they are stored.The display is a ui.render hook on the band above the prompt, plus $.ui.status, $.ui.toast and $.ui.notice.
.claude-plugin/plugin.json manifest + userConfig settings
.claude-plugin/marketplace.json makes this repo installable
hooks/hooks.json points at the hooks module
hooks/register.tsx the hooks: start, tool.call, append, /guard, banner
hooks/rules.ts pure detection logic (no engine calls) — the rules live here
types/index.d.ts the mod's state contract
tests/guard.test.ts 15 behaviour tests (fake values only)
docs/index.html the flow diagram (GitHub Pages)
claude plugin validate .
claude plugin test .
The tests cover each rule family, warn mode, extra forbidden paths, the banner on terminal and desktop surfaces, and redaction. They use fake values only.
To add a token format, add an entry to SHAPES in hooks/rules.ts and a test.
eval, unusual quoting) can pass.cd / git -C. In a single command like git add x && git commit, the commit is checked against what was staged before the command ran.loadEnvValues off if you'd rather they weren't read at all (token-shape detection still works).Built while working on Ex Libris Alma integrations at Tel Aviv University Libraries, after seeing how many quiet paths can carry a key into an agent transcript. The rules mirror a "strict secret-handling mode" that had been living in a CLAUDE.md — this is the attempt to make them hold without depending on the model remembering them.
hooks/register.tsx 375 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardEvent } from '../types'
5import {
6 addedLines,
7 checkBash,
8 dedupe,
9 gitIntents,
10 isForbiddenPath,
11 makeConfig,
12 redact,
13 scanText,
14} from './rules'
15import type { Finding, GitIntent, Known } from './rules'
16
17type $ = EngineInterface
18
19const events = atom({ plugin: 'secret-guard', key: 'events' } as const, [] as GuardEvent[])
20const knownCount = atom({ plugin: 'secret-guard', key: 'knownCount' } as const, 0)
21const isCompact = atom({ plugin: 'secret-guard', key: 'isCompact' } as const, false)
22
23const SHIELD = '\u{1F6E1}'
24const LOG_KEY = 'log'
25
26type Settings = {
27 mode: 'block' | 'warn'
28 banner: string
29 redactOutput: boolean
30 guardGit: boolean
31 loadEnvValues: boolean
32 cfg: ReturnType<typeof makeConfig>
33}
34
35let S: Settings = settingsOf({})
36// The real values live in this module's memory only: never in state, the
37// store, a log line, a toast or anything the model reads.
38let known: Known[] = []
39let loading: Promise<void> | undefined
40
41function settingsOf(options: Record<string, unknown>): Settings {
42 return {
43 mode: options.mode === 'warn' ? 'warn' : 'block',
44 banner: String(options.banner ?? 'full'),
45 redactOutput: options.redactOutput !== false,
46 guardGit: options.guardGit !== false,
47 loadEnvValues: options.loadEnvValues !== false,
48 cfg: makeConfig({
49 secretNames: String(options.secretNames ?? ''),
50 extraForbiddenPaths: String(options.extraForbiddenPaths ?? ''),
51 allowPaths: String(options.allowPaths ?? ''),
52 }),
53 }
54}
55
56async function loadKnown($: $): Promise<void> {
57 if (!S.loadEnvValues) return
58 let run = await $.process.run(['env', '-0'])
59 let sep = '\u0000'
60 if (run.exitCode !== 0) {
61 run = await $.process.run(['env'])
62 sep = '\n'
63 }
64 known = run.stdout
65 .split(sep)
66 .map(line => {
67 const at = line.indexOf('=')
68 return { name: line.slice(0, at), value: line.slice(at + 1) }
69 })
70 .filter(k => k.name.length > 0 && S.cfg.secretName.test(k.name))
71 .filter(k => k.value.length >= 8 && !k.value.startsWith('/'))
72 await update($, knownCount, () => known.length)
73}
74
75function ready($: $): Promise<void> {
76 loading ??= loadKnown($).catch(() => undefined)
77 return loading
78}
79
80async function record($: $, event: Omit<GuardEvent, 'at'>): Promise<void> {
81 const full: GuardEvent = { ...event, at: await $.clock.now() }
82 const list = await update($, events, all => [...all, full].slice(-50))
83 const blocked = list.filter(x => x.kind === 'blocked').length
84 $.ui.status(`${SHIELD} guard on · ${blocked} blocked`)
85 const verb = event.kind === 'blocked' ? 'blocked' : event.kind === 'warned' ? 'flagged' : 'redacted'
86 $.ui.toast(`${SHIELD} secret-guard ${verb} ${event.tool}: ${event.reason}`, { timeoutMs: 8000 })
87 const log = ((await $.store.get(LOG_KEY)) as GuardEvent[] | undefined) ?? []
88 await $.store.set(LOG_KEY, [...log, full].slice(-200))
89}
90
91// -------------------------------------------------------------- inspection
92
93function strings(value: unknown, out: string[] = []): string[] {
94 if (typeof value === 'string') out.push(value)
95 else if (Array.isArray(value)) value.forEach(v => strings(v, out))
96 else if (value !== null && typeof value === 'object') {
97 for (const [k, v] of Object.entries(value)) {
98 if (k !== 'tool' && k !== 'tool_use_id' && k !== 'consent') strings(v, out)
99 }
100 }
101 return out
102}
103
104function within(base: string, dir: string | undefined): string {
105 if (dir === undefined || dir.startsWith('~')) return base
106 return dir.startsWith('/') ? dir : `${base}/${dir}`
107}
108
109async function git($: $, cwd: string, args: string[]): Promise<string> {
110 const run = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 20000 })
111 return run.exitCode === 0 ? run.stdout : ''
112}
113
114function tagged(list: Finding[], tag: string): Finding[] {
115 return list.map(f => ({ ...f, rule: `${tag}:${f.rule}` }))
116}
117
118async function checkGit($: $, intent: GitIntent, base: string): Promise<Finding[]> {
119 const cfg = S.cfg
120 if (intent.kind === 'gh') {
121 const out: Finding[] = []
122 for (const file of intent.files) {
123 if (isForbiddenPath(file, cfg)) {
124 out.push({ rule: 'gh:secret-file', reason: `gh would upload ${file}, a file that holds secrets` })
125 continue
126 }
127 const text = await $.fs.read(file).catch(() => undefined)
128 if (typeof text === 'string') out.push(...tagged(scanText(text, known, `the GitHub text from ${file}`), 'gh'))
129 }
130 return out
131 }
132
133 const cwd = within(base, intent.cwd)
134 if (intent.kind === 'add-all') {
135 const status = await git($, cwd, ['status', '--porcelain', '--untracked-files=all'])
136 const files = status
137 .split('\n')
138 .filter(l => l.length > 3)
139 .map(l => l.slice(3).split(' -> ').pop() ?? '')
140 .filter(p => isForbiddenPath(p, cfg))
141 return files.length === 0
142 ? []
143 : [{ rule: 'git-add-secret-file', reason: `git add would stage ${files.join(', ')}, files that hold secrets` }]
144 }
145
146 if (intent.kind === 'commit') {
147 const range = intent.all ? ['HEAD'] : ['--cached']
148 const names = (await git($, cwd, ['diff', ...range, '--name-only']))
149 .split('\n')
150 .filter(p => p.length > 0 && isForbiddenPath(p, cfg))
151 const diff = await git($, cwd, ['diff', ...range, '--no-color', '-U0'])
152 const out = tagged(scanText(addedLines(diff), known, 'the commit'), 'commit')
153 if (names.length > 0) {
154 out.push({ rule: 'commit:secret-file', reason: `the commit would include ${names.join(', ')}` })
155 }
156 return out
157 }
158
159 // push: every commit not yet on any remote
160 const log = await git($, cwd, ['log', '-p', '--no-color', '--format=', 'HEAD', '--not', '--remotes'])
161 return tagged(scanText(addedLines(log), known, 'the pushed commits'), 'push')
162}
163
164async function inspect($: $, e: { tool: string } & Record<string, unknown>): Promise<Finding[]> {
165 const cfg = S.cfg
166 const tool = e.tool
167 const path = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
168
169 if (tool === 'Bash' && typeof e.command === 'string') {
170 const out = checkBash(e.command, known, cfg)
171 if (S.guardGit) {
172 const base = await $.session.cwd()
173 for (const intent of gitIntents(e.command)) out.push(...(await checkGit($, intent, base)))
174 }
175 return dedupe(out)
176 }
177 if (tool === 'Read' && isForbiddenPath(path, cfg)) {
178 return [{ rule: 'secret-file-read', reason: `Read would load ${path}, a file that holds secrets`, hint: 'ask the user to check it in their own terminal' }]
179 }
180 if (tool === 'Write' && isForbiddenPath(path, cfg)) {
181 return [{ rule: 'secret-file-write', reason: `Write would overwrite ${path}, a file that holds secrets`, hint: 'give the user the line to add, with a <placeholder> for the value' }]
182 }
183 if ((tool === 'Edit' || tool === 'NotebookEdit') && isForbiddenPath(path, cfg)) {
184 // Structural edits of a secrets file are allowed; carrying a value is not.
185 return scanText(strings([e.old_string, e.new_string]).join('\n'), known, `an edit of ${path}`)
186 }
187 if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') {
188 const text = strings([e.content, e.new_string, e.new_source]).join('\n')
189 return scanText(text, known, `the file ${path}`)
190 }
191 return scanText(strings(e).join('\n'), known, `a call to ${tool}`)
192}
193
194function explain(findings: Finding[]): string {
195 return findings.map(f => `- [${f.rule}] ${f.reason}${f.hint ? ` (instead: ${f.hint})` : ''}`).join('\n')
196}
197
198export const register: Register = (on, options) => {
199 S = settingsOf(options)
200 loading = undefined
201 const { mode, banner, redactOutput, guardGit } = S
202
203 // ------------------------------------------------------------ hooks
204
205 on('session.start', async ($, e, next) => {
206 await ready($)
207 await $.command.register({
208 name: 'guard',
209 description: 'Secret guard: status, log, or set mode (block|warn) and banner (full|compact|off)',
210 argumentHint: '[log | block | warn | full | compact | off]',
211 })
212 $.ui.status(`${SHIELD} guard on · ${mode}`)
213 if (banner === 'off') $.ui.toast(`${SHIELD} Secret guard is on (${mode} mode)`)
214 return next(e)
215 })
216
217 on('tool.call', async ($, e, next) => {
218 await ready($)
219 const findings = await inspect($, e as unknown as { tool: string } & Record<string, unknown>)
220 if (findings.length === 0) return next(e)
221
222 const reason = findings.map(f => f.reason).join('; ')
223 const rule = findings.map(f => f.rule).join(', ')
224 if (mode === 'warn') {
225 await record($, { kind: 'warned', rule, reason, tool: e.tool })
226 if (e.tool_use_id) $.ui.notice(e.tool_use_id, `${SHIELD} secret-guard flagged: ${reason}`)
227 return next(e)
228 }
229 await record($, { kind: 'blocked', rule, reason, tool: e.tool })
230 if (e.tool_use_id) $.ui.notice(e.tool_use_id, `${SHIELD} secret-guard blocked this call`)
231 return {
232 deny:
233 `secret-guard blocked this ${e.tool} call because it could expose a secret:\n${explain(findings)}\n` +
234 'Do not retry it in another form. If the user needs the value checked, let them do it in their own terminal.',
235 }
236 }).catch(($, e, next) =>
237 next.called || mode === 'warn'
238 ? next(e)
239 : { deny: 'secret-guard could not check this call, so it was held back. The user can run /guard warn to let calls through.' },
240 )
241
242 on('session.append', async ($, e, next) => {
243 if (!redactOutput) return next(e)
244 await ready($)
245 let count = 0
246 const scrub = (text: string): string => {
247 const r = redact(text, known)
248 count += r.count
249 return r.text
250 }
251 const content = e.message.content.map(block => {
252 const b = block as unknown as Record<string, unknown>
253 if (b.type === 'text' && typeof b.text === 'string') return { ...b, text: scrub(b.text) }
254 if (b.type === 'tool_result') {
255 const inner = b.content
256 if (typeof inner === 'string') return { ...b, content: scrub(inner) }
257 if (Array.isArray(inner)) {
258 return {
259 ...b,
260 content: inner.map(c =>
261 c && typeof c === 'object' && (c as { type?: string }).type === 'text'
262 ? { ...c, text: scrub(String((c as { text?: string }).text ?? '')) }
263 : c,
264 ),
265 }
266 }
267 }
268 return block
269 })
270 if (count === 0) return next(e)
271 await record($, {
272 kind: 'redacted',
273 rule: 'redact',
274 reason: `${count} secret ${count === 1 ? 'value' : 'values'} scrubbed before it was stored`,
275 tool: e.door,
276 })
277 return next({ ...e, message: { ...e.message, content: content as typeof e.message.content } })
278 }).catch(($, e, next) => next(e))
279
280 on('command.run', { command: 'guard' }, async ($, e) => {
281 const arg = e.args.trim().toLowerCase()
282 const rows = await $.config.list()
283 const keyOf = (field: string) => rows.find(r => new RegExp(`^secret-guard(@[^.]+)?\\.${field}$`).test(r.key))?.key
284
285 if (arg === 'block' || arg === 'warn' || arg === 'full' || arg === 'compact' || arg === 'off') {
286 const field = arg === 'block' || arg === 'warn' ? 'mode' : 'banner'
287 const key = keyOf(field)
288 if (key === undefined) return { text: `Could not find the ${field} setting; change it in /config.` }
289 const set = await $.config.set({ key, value: arg })
290 return { text: set.deny ? `Not changed: ${set.deny}` : `Secret guard ${field} is now ${arg}.` }
291 }
292
293 if (arg === 'log') {
294 const log = ((await $.store.get(LOG_KEY)) as GuardEvent[] | undefined) ?? []
295 if (log.length === 0) return { text: 'Secret guard has nothing on record.' }
296 const lines = log.slice(-20).map(x => `${new Date(x.at).toISOString().slice(0, 16)} ${x.kind.padEnd(8)} ${x.tool.padEnd(10)} ${x.reason}`)
297 return { text: `Secret guard, last ${lines.length} events (all sessions):\n${lines.join('\n')}` }
298 }
299
300 const list = await read($, events)
301 const n = (k: GuardEvent['kind']) => list.filter(x => x.kind === k).length
302 return {
303 text: [
304 `${SHIELD} Secret guard is ON, ${mode} mode.`,
305 `Secret variables known by name: ${await read($, knownCount)} (values kept in memory only).`,
306 `This session: ${n('blocked')} blocked, ${n('warned')} flagged, ${n('redacted')} redacted.`,
307 `Watching: secret files, env dumps, echoed/argv secrets, file writes, every outbound tool call${guardGit ? ', git add/commit/push, gh bodies' : ''}${redactOutput ? ', stored output' : ''}.`,
308 'Options: /guard log · /guard block|warn · /guard full|compact|off · more in /config.',
309 ].join('\n'),
310 }
311 })
312
313 // ------------------------------------------------------------ banner
314
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 if (banner === 'off' || e.props.hasSurvey) return next(e)
317 const { Box, Text, Button } = $.ui.resolve(e)
318 const list = await read($, events)
319 const count = await read($, knownCount)
320 const compact = banner === 'compact' || (await read($, isCompact))
321 const blocked = list.filter(x => x.kind === 'blocked').length
322 const flagged = list.filter(x => x.kind === 'warned').length
323 const redacted = list.filter(x => x.kind === 'redacted').length
324 const last = list[list.length - 1]
325 const accent = mode === 'block' ? 'success' : 'warning'
326 const lastColor = last?.kind === 'blocked' ? 'error' : last?.kind === 'warned' ? 'warning' : 'suggestion'
327 const width = Math.max(20, e.props.bodyColumns)
328
329 if (compact) {
330 return (
331 <Box flexDirection="row">
332 <Text color={accent} bold>{SHIELD} SECRET GUARD ON</Text>
333 <Text dimColor wrap="truncate-end">
334 {' '}· {mode} · {blocked} blocked · {redacted} redacted
335 {last ? ` · last: ${last.kind} ${last.tool}` : ''}{' '}
336 </Text>
337 <Button key="expand" plain label="[+]" onPress={() => update($, isCompact, () => false)} />
338 </Box>
339 )
340 }
341
342 return (
343 <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1} width={Math.min(width, 100)}>
344 <Box flexDirection="row" justifyContent="space-between">
345 <Text>
346 <Text color={accent} bold>{SHIELD} SECRET GUARD </Text>
347 <Text backgroundColor={accent} color="inverseText" bold> ON </Text>
348 <Text dimColor> mode </Text>
349 <Text color={accent} bold>{mode.toUpperCase()}</Text>
350 </Text>
351 <Button key="compact" plain label="[–]" onPress={() => update($, isCompact, () => true)} />
352 </Box>
353 <Text dimColor wrap="truncate-end">
354 watching: secret files · env · echo/argv · writes · outbound calls{guardGit ? ' · git · gh' : ''}{redactOutput ? ' · output' : ''}
355 </Text>
356 <Text wrap="truncate-end">
357 <Text color="suggestion">{count}</Text><Text dimColor> secret vars known · </Text>
358 <Text color={blocked > 0 ? 'error' : undefined} bold={blocked > 0}>{blocked}</Text><Text dimColor> blocked · </Text>
359 <Text color={flagged > 0 ? 'warning' : undefined}>{flagged}</Text><Text dimColor> flagged · </Text>
360 <Text color={redacted > 0 ? 'suggestion' : undefined}>{redacted}</Text><Text dimColor> redacted</Text>
361 </Text>
362 {last ? (
363 <Text wrap="truncate-end">
364 <Text color={lastColor} bold>last: {last.kind} </Text>
365 <Text>{last.tool} </Text>
366 <Text dimColor>— {last.reason}</Text>
367 </Text>
368 ) : (
369 <Text dimColor>nothing blocked yet · /guard for status and options</Text>
370 )}
371 </Box>
372 )
373 })
374}
375hooks/rules.ts 342 lines1// Pure detection: no `$`, so every rule is testable on its own.
2// A Finding names the rule and what was at risk, never the secret itself.
3
4export type Finding = { rule: string; reason: string; hint?: string }
5export type Known = { name: string; value: string }
6
7export type Config = {
8 secretName: RegExp
9 forbidden: RegExp[]
10 allowed: RegExp[]
11}
12
13const BUILTIN_FORBIDDEN = [
14 /(^|\/)\.(bashrc|zshrc|profile|bash_profile|bash_aliases|netrc)$/,
15 /(^|\/)\.env(\.[^/]*)?$/,
16 /[^/]\.env$/,
17 /(^|\/)\.aws\/(credentials|config)$/,
18 /(^|\/)(secrets|credentials)\/|(^|\/)(\.psst|\.gnupg|\.ssh)(\/|$)/,
19]
20
21export const DEFAULT_SECRET_NAMES =
22 '(^|_)(KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIALS?|AUTH)(_|$)'
23
24function globToRegex(glob: string): RegExp {
25 const body = glob
26 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
27 .replace(/\*\*\/?/g, '\u0000')
28 .replace(/\*/g, '[^/]*')
29 .replace(/\?/g, '[^/]')
30 .replace(/\u0000/g, '(.*/)?')
31 return new RegExp(`(^|/)${body}(/|$)`)
32}
33
34function globs(list: string): RegExp[] {
35 return list
36 .split(',')
37 .map(one => one.trim())
38 .filter(one => one.length > 0)
39 .map(globToRegex)
40}
41
42export function makeConfig(o: {
43 secretNames?: string
44 extraForbiddenPaths?: string
45 allowPaths?: string
46}): Config {
47 let secretName: RegExp
48 try {
49 secretName = new RegExp(o.secretNames || DEFAULT_SECRET_NAMES, 'i')
50 } catch {
51 secretName = new RegExp(DEFAULT_SECRET_NAMES, 'i')
52 }
53 return {
54 secretName,
55 forbidden: [...BUILTIN_FORBIDDEN, ...globs(o.extraForbiddenPaths ?? '')],
56 allowed: globs(o.allowPaths ?? ''),
57 }
58}
59
60export function isForbiddenPath(path: string, c: Config): boolean {
61 const p = path.replace(/^['"]|['"]$/g, '').replace(/\/+$/, '')
62 if (p.length === 0) return false
63 if (c.allowed.some(r => r.test(p))) return false
64 return c.forbidden.some(r => r.test(p))
65}
66
67// ---------------------------------------------------------------- shapes
68
69const SHAPES: { name: string; re: RegExp }[] = [
70 { name: 'AWS access key', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g },
71 { name: 'GitHub token', re: /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
72 { name: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
73 { name: 'OpenAI-style key', re: /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/g },
74 { name: 'Slack token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
75 { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
76 { name: 'Ex Libris API key', re: /\bl7xx[0-9a-f]{32}\b/g },
77 { name: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
78 { name: 'JWT', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
79 {
80 name: 'hard-coded credential',
81 re: /\b(api[_-]?key|secret|token|passw(or)?d)\b["']?\s*[:=]\s*["'][^"'\s$<{]{12,}["']/gi,
82 },
83]
84
85export function findShapes(text: string): string[] {
86 const hits: string[] = []
87 for (const { name, re } of SHAPES) {
88 re.lastIndex = 0
89 if (re.test(text)) hits.push(name)
90 }
91 return hits
92}
93
94export function findKnown(text: string, known: readonly Known[]): string[] {
95 return known.filter(k => text.includes(k.value)).map(k => k.name)
96}
97
98/** Scan any text bound for somewhere it would persist or leave the machine. */
99export function scanText(text: string, known: readonly Known[], where: string): Finding[] {
100 const out: Finding[] = []
101 const names = findKnown(text, known)
102 if (names.length > 0) {
103 out.push({
104 rule: 'secret-value',
105 reason: `the value of ${names.join(', ')} would appear in ${where}`,
106 hint: 'refer to the variable by name, or pipe it: printenv NAME | cmd --stdin',
107 })
108 }
109 const shapes = findShapes(text)
110 if (shapes.length > 0) {
111 out.push({
112 rule: 'secret-shape',
113 reason: `text shaped like a ${shapes.join(', ')} would appear in ${where}`,
114 hint: 'use a <placeholder> instead of a real-looking value',
115 })
116 }
117 return out
118}
119
120export function redact(text: string, known: readonly Known[]): { text: string; count: number } {
121 let count = 0
122 let out = text
123 // Longest first, so a value that contains another is replaced whole.
124 for (const k of [...known].sort((a, b) => b.value.length - a.value.length)) {
125 if (out.includes(k.value)) {
126 count += out.split(k.value).length - 1
127 out = out.split(k.value).join(`[redacted:${k.name}]`)
128 }
129 }
130 for (const { name, re } of SHAPES) {
131 if (name === 'hard-coded credential') continue
132 re.lastIndex = 0
133 out = out.replace(re, () => {
134 count += 1
135 return `[redacted:${name}]`
136 })
137 }
138 return { text: out, count }
139}
140
141// ---------------------------------------------------------------- bash
142
143/** Split into simple commands, keeping the separator that followed each. */
144export function segments(command: string): { text: string; then: string }[] {
145 const out: { text: string; then: string }[] = []
146 const re = /(\|\||&&|\||;|\n)/g
147 let last = 0
148 let m: RegExpExecArray | null
149 while ((m = re.exec(command)) !== null) {
150 const sep = m[1] ?? ''
151 out.push({ text: command.slice(last, m.index).trim(), then: sep })
152 last = m.index + sep.length
153 }
154 out.push({ text: command.slice(last).trim(), then: '' })
155 return out.filter(s => s.text.length > 0)
156}
157
158export function words(segment: string): string[] {
159 return segment
160 .split(/[\s<>()]+/)
161 .map(w => w.replace(/^['"]+|['"]+$/g, ''))
162 .filter(w => w.length > 0)
163}
164
165const READ_VERB =
166 /^(cat|head|tail|less|more|bat|batcat|grep|egrep|fgrep|rg|ag|sed|awk|gawk|strings|xxd|od|hexdump|base64|nl|tac|cut|sort|uniq|diff|cmp|cp|mv|scp|rsync|tee|vi|vim|nvim|nano|emacs|code|jq|yq|python|python3|node|ruby|perl|php|curl|wget|zip|tar|gzip)$/
167
168const DUMP = /^(env|printenv|set|export|declare\s+-[px]+|export\s+-p|typeset\s+-[px]+)$/
169
170const SAFE_DUMP_FILTER = /cut\s+-d\s*['"]?=['"]?\s+-f\s*1\b|sed\s+-E?\s*['"]s\/=\.\*|grep\s+-c\b|wc\b/
171
172function isRedactingPipeline(rest: string): boolean {
173 return /<redacted>/.test(rest) || SAFE_DUMP_FILTER.test(rest)
174}
175
176export function checkBash(command: string, known: readonly Known[], c: Config): Finding[] {
177 const out: Finding[] = []
178 const segs = segments(command)
179
180 segs.forEach((seg, i) => {
181 const w = words(seg.text)
182 const verb = w[0] ?? ''
183 const rest = segs.slice(i + 1).map(s => s.text).join(' | ')
184 const piped = seg.then === '|'
185
186 // 1. reading a secret file
187 const paths = w.slice(1).filter(x => isForbiddenPath(x, c))
188 if (paths.length > 0 && READ_VERB.test(verb) && !isRedactingPipeline(seg.text + ' ' + rest)) {
189 out.push({
190 rule: 'secret-file-read',
191 reason: `${verb} would print ${paths.join(', ')}, a file that holds secrets`,
192 hint: "check for a name only: grep -nE '^export NAME=' FILE | sed -E 's/=.*/=<redacted>/'",
193 })
194 }
195
196 // 2. dumping the whole environment
197 const dumpOf = w.slice(0, 2).join(' ')
198 if ((DUMP.test(verb) && w.length === 1) || DUMP.test(dumpOf) && w.length === 2) {
199 if (!piped || !isRedactingPipeline(rest)) {
200 out.push({
201 rule: 'env-dump',
202 reason: `${seg.text} would print every variable's value`,
203 hint: 'list names only: env | cut -d= -f1 | sort',
204 })
205 }
206 }
207
208 // 3. printenv NAME of a secret, not piped onward
209 if (verb === 'printenv' && w.length >= 2 && !piped) {
210 const named = w.slice(1).filter(n => c.secretName.test(n))
211 if (named.length > 0) {
212 out.push({
213 rule: 'printenv-secret',
214 reason: `printenv ${named.join(' ')} would print the value`,
215 hint: 'compare digests instead: printenv NAME | sha256sum | cut -c1-12',
216 })
217 }
218 }
219
220 // 4. expanding a secret variable
221 const refs = [...seg.text.matchAll(/\$\{?(#?)([A-Za-z_][A-Za-z0-9_]*)(\}|:[+\-=0]|[-=]|)?/g)]
222 for (const r of refs) {
223 const [whole, hash, name = '', after] = r
224 if (!c.secretName.test(name) || hash === '#') continue
225 if (after === ':+' || after === ':0') continue
226 if (after === ':-' || after === '-' || after === ':=' || after === '=') {
227 out.push({
228 rule: 'default-expansion',
229 reason: `${whole}… expands to the value of ${name} when it is set`,
230 hint: 'test presence with ${NAME:+set} or length with ${#NAME}',
231 })
232 continue
233 }
234 const before = seg.text.slice(0, r.index)
235 if (/<<<\s*["']?$/.test(before)) continue
236 if (/(\[\[?|test)\s+-[nz]\s+["']?$/.test(before)) continue
237 if (/^(export|local|declare|readonly|typeset)\b/.test(verb) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(verb)) {
238 continue
239 }
240 if (/^(echo|printf|print)$/.test(verb)) {
241 out.push({
242 rule: 'echo-secret',
243 reason: `${verb} would print the value of ${name}`,
244 hint: 'show presence only: [ -n "$NAME" ] && echo set || echo unset',
245 })
246 } else {
247 out.push({
248 rule: 'secret-on-argv',
249 reason: `${verb} would receive the value of ${name} on its command line (visible in ps and logs)`,
250 hint: 'pass it on stdin: printenv NAME | cmd --stdin, or cmd <<< "$NAME"',
251 })
252 }
253 }
254
255 // 5. staging a secret file
256 if (verb === 'git' && w.includes('add')) {
257 const staged = w.slice(w.indexOf('add') + 1).filter(x => !x.startsWith('-') && isForbiddenPath(x, c))
258 if (staged.length > 0) {
259 out.push({
260 rule: 'git-add-secret-file',
261 reason: `git add would stage ${staged.join(', ')}, a file that holds secrets`,
262 })
263 }
264 }
265 })
266
267 out.push(...scanText(command, known, 'the command'))
268 return dedupe(out)
269}
270
271export function dedupe(list: Finding[]): Finding[] {
272 const seen = new Set<string>()
273 return list.filter(f => {
274 const k = f.rule + '|' + f.reason
275 if (seen.has(k)) return false
276 seen.add(k)
277 return true
278 })
279}
280
281/** Only the added lines of a unified diff, skipping file headers. */
282export function addedLines(diff: string): string {
283 return diff
284 .split('\n')
285 .filter(l => l.startsWith('+') && !l.startsWith('+++'))
286 .map(l => l.slice(1))
287 .join('\n')
288}
289
290// ---------------------------------------------------------------- git / gh shapes
291
292export type GitIntent =
293 | { kind: 'add-all'; cwd?: string }
294 | { kind: 'commit'; cwd?: string; all: boolean }
295 | { kind: 'push'; cwd?: string }
296 | { kind: 'gh'; files: string[] }
297
298export function gitIntents(command: string): GitIntent[] {
299 const out: GitIntent[] = []
300 let cwd: string | undefined
301 for (const seg of segments(command)) {
302 const w = words(seg.text)
303 if (w[0] === 'cd' && w[1]) cwd = w[1]
304 if (w[0] === 'git') {
305 let i = 1
306 let here = cwd
307 while (i < w.length && (w[i] ?? '').startsWith('-')) {
308 if (w[i] === '-C') {
309 here = w[i + 1]
310 i += 2
311 } else if (w[i] === '-c') {
312 i += 2
313 } else i += 1
314 }
315 const sub = w[i]
316 const args = w.slice(i + 1)
317 if (sub === 'add' && args.some(a => a === '-A' || a === '--all' || a === '.' || a === '-u' || a === '--update')) {
318 out.push({ kind: 'add-all', cwd: here })
319 }
320 if (sub === 'commit') {
321 const all = args.some(a => a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a))
322 out.push({ kind: 'commit', cwd: here, all })
323 }
324 if (sub === 'push') out.push({ kind: 'push', cwd: here })
325 }
326 if (w[0] === 'gh') {
327 const files: string[] = []
328 w.forEach((x, j) => {
329 const nextWord = w[j + 1]
330 if (/^(--body-file|-F|--notes-file|--input)$/.test(x) && nextWord) files.push(nextWord)
331 const eq = /^(--body-file|--notes-file|--input)=(.+)$/.exec(x)
332 if (eq?.[2]) files.push(eq[2])
333 })
334 if (w[1] === 'gist' && w[2] === 'create') {
335 files.push(...w.slice(3).filter(x => !x.startsWith('-')))
336 }
337 out.push({ kind: 'gh', files })
338 }
339 }
340 return out
341}
342types/index.d.ts 16 lines1export type GuardEvent = {
2 /** 'blocked' refused the call; 'warned' let it run (warn mode); 'redacted' scrubbed stored text. */
3 kind: 'blocked' | 'warned' | 'redacted'
4 rule: string
5 /** What was at risk, never the secret itself. */
6 reason: string
7 tool: string
8 at: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'secret-guard': { events: GuardEvent[]; knownCount: number; isCompact: boolean }
14 }
15}
16