Masks secrets in tool results and blocks writing or committing them.

Masks secrets in tool results, refuses to write keys into source files and blocks git commits whose staged changes contain secrets. Files named .env are allowed.
/plugin marketplace add namtrdev/claude-mods
/plugin install secret-guard@jackie-mods
Commands: /secret-guard.
Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.
You set these when you install the mod, and can change them later in the Claude Code config menu.
maskResults, default true): Hide secrets in what Read, Bash and other tools return to the model.onWrite, default block): block refuses the write; warn shows a notice and lets it through.guardCommits, default true): Refuse git commit when the staged changes hold a secret.extraAllowedFiles, default empty): Regular expressions for file paths, comma separated. Files named .env are always allowed. Example: secrets\.yaml$,\.local\.json$MIT, Copyright (c) 2026 Jackie.
hooks/register.tsx 143 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const isOn = atom({ plugin: 'secret-guard', key: 'isOn' } as const, true)
5const masked = atom({ plugin: 'secret-guard', key: 'masked' } as const, 0)
6
7const RULES: { label: string; re: RegExp }[] = [
8 { label: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
9 { label: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
10 { label: 'api-key', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
11 { label: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{30,}/g },
12 { label: 'aws-key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
13 { label: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
14 { label: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
15 { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
16 { label: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g },
17 { label: 'secret', re: /(?<=\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\s*[=:]\s*["']?)[^\s"']{4,}/gi },
18]
19
20// Files meant to hold secrets.
21const ALLOWED = /(^|\/)\.env(\.[\w.-]+)?$/
22
23const labelsIn = (text: string) => {
24 const found: string[] = []
25 for (const rule of RULES) {
26 for (const _ of text.matchAll(rule.re)) found.push(rule.label)
27 }
28 return found
29}
30
31const maskText = (text: string, tally: { n: number }) => {
32 let out = text
33 for (const rule of RULES) {
34 out = out.replace(rule.re, () => {
35 tally.n += 1
36 return rule.label === 'secret' ? '[hidden:secret]' : `[hidden:${rule.label}]`
37 })
38 }
39 return out
40}
41
42const deepMask = (value: unknown, tally: { n: number }): unknown => {
43 if (typeof value === 'string') return maskText(value, tally)
44 if (Array.isArray(value)) return value.map(one => deepMask(one, tally))
45 if (value !== null && typeof value === 'object') {
46 return Object.fromEntries(
47 Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, deepMask(v, tally)]),
48 )
49 }
50 return value
51}
52
53const parse = (raw: unknown) =>
54 String(raw ?? '')
55 .split(',')
56 .map(one => one.trim())
57 .filter(one => one !== '')
58 .flatMap(one => {
59 try {
60 return [new RegExp(one)]
61 } catch {
62 return []
63 }
64 })
65
66export const register: Register = (on, options) => {
67 const extraAllowed = parse(options.extraAllowedFiles)
68 const warnOnly = options.onWrite === 'warn'
69 const guardCommits = options.guardCommits !== false
70 const maskResults = options.maskResults !== false
71
72 on('session.start', async ($, e, next) => {
73 await $.command.register({
74 name: 'secret-guard',
75 description: 'Turn the secret guard (tool results, writes, commits) on or off',
76 })
77
78 return next(e)
79 })
80
81 on('command.run', { command: 'secret-guard' }, async $ => {
82 const was = await read($, isOn)
83 await update($, isOn, () => !was)
84 $.ui.status(was ? 'secret guard OFF' : undefined)
85
86 return { text: was ? 'Secret guard is OFF.' : 'Secret guard is ON.' }
87 })
88
89 on('tool.call', async ($, e, next) => {
90 if (!(await read($, isOn))) {
91 return next(e)
92 }
93 const input = e as unknown as Record<string, unknown>
94
95 if (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'MultiEdit') {
96 const path = String(input.file_path ?? '')
97 const edits = Array.isArray(input.edits) ? (input.edits as { new_string?: unknown }[]) : []
98 const body = [input.content, input.new_string, ...edits.map(one => one.new_string)]
99 .filter((one): one is string => typeof one === 'string')
100 .join('\n')
101 const found = ALLOWED.test(path) || extraAllowed.some(re => re.test(path)) ? [] : labelsIn(body)
102 if (found.length > 0) {
103 const why = `secret-guard: ${path} would contain ${[...new Set(found)].join(', ')}.`
104 if (warnOnly) {
105 $.ui.toast(why)
106 } else {
107 return { deny: `${why} Read it from an environment variable or a .env file instead.` }
108 }
109 }
110 }
111
112 if (guardCommits && e.tool === 'Bash' && /\bgit\s+commit\b/.test(String(input.command ?? ''))) {
113 try {
114 const staged = await $.tool.call({ tool: 'Bash', command: 'git diff --cached' })
115 const found = labelsIn(String((staged as { text?: string }).text ?? ''))
116 if (found.length > 0) {
117 return {
118 deny: `secret-guard: staged changes contain ${[...new Set(found)].join(', ')}. Unstage them before committing.`,
119 }
120 }
121 } catch {
122 // No repository or git unavailable: nothing to check.
123 }
124 }
125
126 const ran = await next(e)
127 if (!maskResults || ran.deny !== undefined || ran.result === undefined) {
128 return ran
129 }
130 const tally = { n: 0 }
131 const clean = deepMask(ran.result, tally)
132 if (tally.n === 0) {
133 return ran
134 }
135 await update($, masked, n => n + tally.n)
136 $.ui.toast(`secret-guard hid ${tally.n} secret${tally.n > 1 ? 's' : ''} in ${e.tool} output`)
137
138 return ran.context === undefined
139 ? { result: clean as never }
140 : { result: clean as never, context: ran.context }
141 })
142}
143types/index.d.ts 8 lines1export type GuardCount = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'secret-guard': { isOn: boolean; masked: GuardCount }
6 }
7}
8