Masks API keys and tokens in tool results before Claude reads them, and blocks a git push that would leak secrets or home paths to a public repo.

A Claude Code mod that keeps secrets out of the conversation and out of public repos.
git push that would leak a secret or a home path to a public repo./plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install secret-guard@claude-code-mods
Or try it without installing:
git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/secret-guard
Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.
Every tool result is checked before it is stored: built-in tools, MCP tools, the main thread and subagents. Each secret becomes a marker. The rest of the text is kept byte for byte.
ANTHROPIC_API_KEY=[masked anthropic_key ...a1b2]
The marker shows the kind and the last 4 characters.
What it finds:
ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), AWS AKIA, Google AIza, Slack, Stripe, GitLab, Apify, Notion, Hugging Face, Replicate, npm, Telegram bot tokens, JWTs.Bearer followed by 20 or more token characters).postgres://user:REDACTED@host.api_key, secret, token, password, credential, private_key, access_key or auth, then = or :, then a value of 16 or more characters with a letter and a digit. Only the value is masked.What it leaves alone: placeholders (your_..., xxx..., <...>, ${...}, process.env..., os.environ..., changeme, example), git SHAs, UUIDs, numbers, file paths, npm sha512- integrity hashes.
Claude now sees markers, so it could write one back over the real secret. The write guard stops that. Write, Edit, MultiEdit and NotebookEdit are denied when the new text holds a marker, and so is a Bash command that holds one. Claude is told to edit around the line or ask you to change that value.
When a Bash command runs git push, secret-guard checks before it runs:
git -C <dir>, a leading cd <dir> &&, or the session folder) and the remote (default origin).gh repo view for the visibility. PRIVATE or INTERNAL: the push goes through, no scan. PUBLIC or unknown (no gh, not GitHub, an error): it scans.git add or git commit, it scans the working tree diff and the untracked files too (not ignored, under 1 MB, not binary)./home/<name>/ or /Users/<name>/ path, and Windows paths (C:\Users\<name>\, the forward-slash form and the JSON-escaped form, any drive letter). HOME and, when set, USERPROFILE count as your home folder. /home/user/, /home/runner/ and the Windows names Public, Default, Default User and All Users are ignored.With findings, the push is denied. The text lists up to 10 as file:line kind. It never prints the secret. A toast says secret-guard: push blocked, N findings. When the visibility was unknown, the text says so.
If the scan itself fails (git error, timeout of about 5 seconds), the push is allowed and a toast says secret-guard: scan failed, push allowed.
| Command | What it does |
|---|---|
/secret-guard | Shows how many values were masked this session and whether a pass is active. |
/secret-guard allow | Lets the next push skip the scan. The pass lasts 10 minutes and works once. |
For a false positive: run /secret-guard allow, then push again.
git log output is cut at 4 MB. A very large unpushed history is only scanned in part.No options.
MIT
hooks/register.ts 157 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Guard } from '../types'
5import { hasMarker, maskBlocks, writtenText } from './detect'
6import { denyText, fileAdded, addedLines, parsePushes, repoSpec, scanAdded } from './push'
7import type { Added, Finding, Push } from './push'
8
9const guard = atom({ plugin: 'secret-guard', key: 'guard' } as const, { masked: 0, passUntil: 0 } as Guard)
10
11const PASS_MS = 10 * 60 * 1000
12const SCAN_MS = 5000
13const MAX_UNTRACKED = 200
14
15const MARKER_DENY =
16 'secret-guard: this content holds a masked secret marker. The file holds a masked secret: edit around it or ask the user to change that value.'
17
18type Scan = { findings: Finding[]; visibilityKnown: boolean }
19
20// Scans one push. Throws when the scan itself fails; the caller then lets the push through.
21async function scanPush($: EngineInterface, p: Push, home: string | undefined, homes: Array<string | undefined>, t0: number): Promise<Scan> {
22 const run = async (argv: string[], cwd?: string) => {
23 const left = SCAN_MS - ((await $.clock.now()) - t0)
24 if (left <= 0) throw new Error('scan timed out')
25 return $.process.run(argv, cwd === undefined ? { timeoutMs: left } : { cwd, timeoutMs: left })
26 }
27 const base = p.chdir?.replace(/^~(?=\/|$)/, home ?? '~')
28 const gitC = p.gitDir === undefined ? [] : ['-C', p.gitDir]
29
30 const topRun = await run(['git', ...gitC, 'rev-parse', '--show-toplevel'], base)
31 const top = topRun.stdout.trim()
32 if (topRun.exitCode !== 0 || top === '') return { findings: [], visibilityKnown: true }
33
34 const remoteUrl = await run(['git', 'remote', 'get-url', p.remote], top)
35 const url = remoteUrl.exitCode === 0 ? remoteUrl.stdout.trim() : /[:/]/.test(p.remote) ? p.remote : ''
36 let visibility = ''
37 if (url !== '') {
38 try {
39 const r = await run(['gh', 'repo', 'view', repoSpec(url), '--json', 'visibility', '--jq', '.visibility'], top)
40 if (r.exitCode === 0) visibility = r.stdout.trim().toUpperCase()
41 } catch {}
42 }
43 if (visibility === 'PRIVATE' || visibility === 'INTERNAL') return { findings: [], visibilityKnown: true }
44
45 const pending = async (): Promise<Added[]> => {
46 const out: Added[] = []
47 const diffArgs = ['--no-pager', 'diff', '--no-color', '--no-ext-diff']
48 let d = await run(['git', ...diffArgs, 'HEAD'], top)
49 if (d.exitCode !== 0) d = await run(['git', ...diffArgs, '--cached'], top)
50 if (d.exitCode === 0) out.push(...addedLines(d.stdout))
51 const ls = await run(['git', 'ls-files', '-z', '-o', '--exclude-standard'], top)
52 if (ls.exitCode !== 0) return out
53 const files = ls.stdout.split('\0').filter((f) => f !== '').slice(0, MAX_UNTRACKED)
54 const texts = await Promise.all(files.map((f) => run(['head', '-c', String(1024 * 1024 + 1), '--', f], top)))
55 texts.forEach((t, i) => {
56 if (t.exitCode === 0) out.push(...fileAdded(files[i] as string, t.stdout))
57 })
58 return out
59 }
60
61 const [log, extra] = await Promise.all([
62 run(
63 ['git', '--no-pager', 'log', '-p', '--no-color', '--no-ext-diff', '--format=%H', '--branches', '--not', `--remotes=${p.remote}`],
64 top,
65 ),
66 p.staged ? pending() : Promise.resolve([] as Added[]),
67 ])
68 if (log.exitCode !== 0) throw new Error('git log failed')
69 const findings = scanAdded([...addedLines(log.stdout), ...extra], homes)
70 return { findings, visibilityKnown: visibility === 'PUBLIC' }
71}
72
73// A deny text for the call, or undefined to let it run.
74async function verdict($: EngineInterface, e: { tool: string }, command: string | undefined): Promise<string | undefined> {
75 if (writtenText(e.tool, e as unknown as Record<string, unknown>).some(hasMarker)) return MARKER_DENY
76 if (command === undefined) return undefined
77 const pushes = parsePushes(command)
78 if (pushes.length === 0) return undefined
79
80 const now = await $.clock.now()
81 if ((await read($, guard)).passUntil > now) {
82 await update($, guard, (g) => ({ ...g, passUntil: 0 }))
83 return undefined
84 }
85 const home = await $.env.get('HOME')
86 const homes = [home, await $.env.get('USERPROFILE')]
87 const findings: Finding[] = []
88 let known = true
89 try {
90 for (const p of pushes) {
91 const r = await scanPush($, p, home, homes, now)
92 findings.push(...r.findings)
93 known = known && r.visibilityKnown
94 }
95 } catch {
96 $.ui.toast('secret-guard: scan failed, push allowed')
97 return undefined
98 }
99 if (findings.length === 0) return undefined
100 $.ui.toast(`secret-guard: push blocked, ${findings.length} findings`)
101 return denyText(findings, known)
102}
103
104export const register: Register = (on) => {
105 on('session.start', async ($, e, next) => {
106 try {
107 await $.command.register({
108 name: 'secret-guard',
109 description: 'Show secret-guard status, or let the next push skip the scan',
110 argumentHint: '[allow]',
111 })
112 } catch {}
113 return next(e)
114 })
115
116 on('session.append', async ($, e, next) => {
117 let masked: { content: typeof e.message.content; count: number } | null = null
118 try {
119 if (e.door === 'tool-result' || e.door === 'tool-message') {
120 const r = maskBlocks(e.message.content)
121 if (r.count > 0) masked = r
122 }
123 } catch {}
124 if (masked === null) return next(e)
125 const stored = await next({ ...e, message: { ...e.message, content: masked.content } })
126 try {
127 const n = masked.count
128 await update($, guard, (g) => ({ ...g, masked: g.masked + n }))
129 } catch {}
130 return stored
131 })
132
133 on('tool.call', async ($, e, next) => {
134 let deny: string | undefined
135 try {
136 deny = await verdict($, e, e.tool === 'Bash' ? e.command : undefined)
137 } catch {}
138 return deny === undefined ? next(e) : { deny }
139 })
140
141 on('command.run', { command: 'secret-guard' }, async ($, e, next) => {
142 try {
143 const now = await $.clock.now()
144 if (e.args.trim() === 'allow') {
145 await update($, guard, (g) => ({ ...g, passUntil: now + PASS_MS }))
146 return { text: 'secret-guard: the next push skips the scan. The pass lasts 10 minutes.' }
147 }
148 const g = await read($, guard)
149 const left = Math.ceil((g.passUntil - now) / 60000)
150 const pass = g.passUntil > now ? `a pass is active (${left} min left)` : 'no pass is active'
151 return { text: `secret-guard: ${g.masked} values masked this session, ${pass}.` }
152 } catch {
153 return { text: 'secret-guard: something went wrong' }
154 }
155 })
156}
157hooks/detect.ts 175 lines1// Pure secret detection and masking. No `$` here, so tests can call it directly.
2
3export type Secret = { kind: string; start: number; end: number }
4
5type Rule = { kind: string; re: RegExp; ok?: (s: string) => boolean }
6
7const hasLetterAndDigit = (s: string): boolean => /[A-Za-z]/.test(s) && /[0-9]/.test(s)
8
9// Left boundary: a token never starts in the middle of a word.
10const PREFIXED: Rule[] = [
11 { kind: 'anthropic_key', re: /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}/g },
12 { kind: 'openai_key', re: /(?<![A-Za-z0-9])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, ok: hasLetterAndDigit },
13 { kind: 'github_token', re: /(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{36,}/g },
14 { kind: 'github_token', re: /(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{50,}/g },
15 { kind: 'aws_key', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/g },
16 { kind: 'google_key', re: /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/g },
17 { kind: 'slack_token', re: /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}/g },
18 { kind: 'stripe_key', re: /(?<![A-Za-z0-9])(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
19 { kind: 'stripe_key', re: /(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{16,}/g },
20 { kind: 'gitlab_token', re: /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}/g },
21 { kind: 'apify_token', re: /(?<![A-Za-z0-9])apify_api_[A-Za-z0-9]{20,}/g },
22 { kind: 'notion_token', re: /(?<![A-Za-z0-9])ntn_[A-Za-z0-9]{30,}/g },
23 { kind: 'notion_token', re: /(?<![A-Za-z0-9])secret_[A-Za-z0-9]{40,}/g },
24 { kind: 'huggingface_token', re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,}/g },
25 { kind: 'replicate_token', re: /(?<![A-Za-z0-9])r8_[A-Za-z0-9]{30,}/g },
26 { kind: 'npm_token', re: /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{30,}/g },
27 { kind: 'telegram_token', re: /(?<![0-9])\d{8,10}:AA[A-Za-z0-9_-]{33}/g },
28 { kind: 'jwt', re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g },
29 {
30 kind: 'private_key',
31 re: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g,
32 },
33 // A block cut short before its END line: the BEGIN line and the body that follows.
34 { kind: 'private_key', re: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----\s*(?:[A-Za-z0-9+/=]{20,}\s*)+/g },
35]
36
37const BEARER = /\bbearer[ \t]+([A-Za-z0-9._~+/-]{20,}=*)/gi
38
39const URL_PASSWORD = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s:/@"'<>]+:([^\s@/"'<>]+)@/g
40
41const ASSIGNMENT =
42 /(?:api[_-]?key|secret|token|passw(?:or)?d|credential|private[_-]?key|access[_-]?key|auth(?!or))[A-Za-z0-9_.-]*["']?[ \t]*[=:][ \t]*["']?([A-Za-z0-9_\-./+=]{16,})/gi
43
44const PLACEHOLDER =
45 /^(?:your|x{3,}|\*+$|<|\$|process\.env|os\.environ|os\.getenv)|changeme|change_me|example/i
46
47const GENERIC_PASSWORD = /^(?:pass|password|passwd|pwd|secret|user|x+|\*+)$/i
48
49const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
50
51function isAssignedSecret(v: string): boolean {
52 if (!hasLetterAndDigit(v)) return false
53 if (PLACEHOLDER.test(v) || /^(.)\1+$/.test(v)) return false
54 if (UUID.test(v) || /^[0-9a-f]{40}$/i.test(v)) return false
55 if (/^sha\d+-/.test(v)) return false
56 if (/^(?:\/|\.\.?\/|~)/.test(v)) return false
57 return true
58}
59
60// Priority: a prefixed token wins over the assignment rule at the same spot.
61function collect(text: string): Array<Secret & { rank: number }> {
62 const found: Array<Secret & { rank: number }> = []
63 for (const rule of PREFIXED) {
64 for (const m of text.matchAll(rule.re)) {
65 if (rule.ok && !rule.ok(m[0])) continue
66 found.push({ kind: rule.kind, start: m.index, end: m.index + m[0].length, rank: 0 })
67 }
68 }
69 for (const m of text.matchAll(URL_PASSWORD)) {
70 const pw = m[1] as string
71 if (PLACEHOLDER.test(pw) || GENERIC_PASSWORD.test(pw)) continue
72 const end = m.index + m[0].length - 1
73 found.push({ kind: 'url_password', start: end - pw.length, end, rank: 1 })
74 }
75 for (const m of text.matchAll(BEARER)) {
76 const v = m[1] as string
77 if (!hasLetterAndDigit(v) || PLACEHOLDER.test(v)) continue
78 const end = m.index + m[0].length
79 found.push({ kind: 'bearer_token', start: end - v.length, end, rank: 2 })
80 }
81 for (const m of text.matchAll(ASSIGNMENT)) {
82 const v = m[1] as string
83 if (!isAssignedSecret(v)) continue
84 const end = m.index + m[0].length
85 found.push({ kind: 'secret', start: end - v.length, end, rank: 2 })
86 }
87 return found
88}
89
90// Every secret in the text, in order, none overlapping.
91export function findSecrets(text: string): Secret[] {
92 const all = collect(text).sort((a, b) => a.start - b.start || a.rank - b.rank || b.end - a.end)
93 const out: Secret[] = []
94 let last = 0
95 for (const s of all) {
96 if (s.start < last) continue
97 out.push({ kind: s.kind, start: s.start, end: s.end })
98 last = s.end
99 }
100 return out
101}
102
103function tail(kind: string, s: string): string {
104 const body = kind === 'private_key' ? s.replace(/-----END[\s\S]*$/, '').replace(/[\s-]/g, '') : s
105 return body.slice(-4)
106}
107
108export function mask(text: string): { text: string; count: number } {
109 const found = findSecrets(text)
110 if (found.length === 0) return { text, count: 0 }
111 let out = ''
112 let at = 0
113 for (const s of found) {
114 out += text.slice(at, s.start) + `[masked ${s.kind} ...${tail(s.kind, text.slice(s.start, s.end))}]`
115 at = s.end
116 }
117 return { text: out + text.slice(at), count: found.length }
118}
119
120type Block = { type: string; [field: string]: unknown }
121
122// Masks text blocks and the text inside tool_result blocks. Every other block is kept as is.
123export function maskBlocks(blocks: Block[]): { content: Block[]; count: number } {
124 let count = 0
125 const text = (s: string): string => {
126 const r = mask(s)
127 count += r.count
128 return r.text
129 }
130 const content = blocks.map((b): Block => {
131 if (b.type === 'text' && typeof b.text === 'string') {
132 const t = text(b.text)
133 return t === b.text ? b : { ...b, text: t }
134 }
135 if (b.type !== 'tool_result') return b
136 if (typeof b.content === 'string') {
137 const t = text(b.content)
138 return t === b.content ? b : { ...b, content: t }
139 }
140 if (!Array.isArray(b.content)) return b
141 const inner = (b.content as Block[]).map((c): Block => {
142 if (c.type !== 'text' || typeof c.text !== 'string') return c
143 const t = text(c.text)
144 return t === c.text ? c : { ...c, text: t }
145 })
146 return { ...b, content: inner }
147 })
148 return { content: count === 0 ? blocks : content, count }
149}
150
151const MARKER = /\[masked [a-z_]+ \.\.\.[^\]\s]{1,4}\]/
152
153export const hasMarker = (s: string): boolean => MARKER.test(s)
154
155// The text a tool call would write or run, for the write guard.
156export function writtenText(tool: string, input: Record<string, unknown>): string[] {
157 const str = (v: unknown): string[] => (typeof v === 'string' ? [v] : [])
158 switch (tool) {
159 case 'Write':
160 return str(input.content)
161 case 'Edit':
162 return str(input.new_string)
163 case 'MultiEdit':
164 return Array.isArray(input.edits)
165 ? input.edits.flatMap((x) => str((x as Record<string, unknown> | null)?.new_string))
166 : []
167 case 'NotebookEdit':
168 return str(input.new_source)
169 case 'Bash':
170 return str(input.command)
171 default:
172 return []
173 }
174}
175hooks/push.ts 207 lines1// Pure helpers for the push check. No `$` here, so tests can call them directly.
2import { findSecrets } from './detect'
3
4export type Push = {
5 /** Folder from a leading `cd <dir>`, run from the session's cwd. */
6 chdir?: string
7 /** Folder from `git -C <dir>`, relative to chdir when both are given. */
8 gitDir?: string
9 remote: string
10 /** True when the command stages or commits before the push. */
11 staged: boolean
12}
13
14export type Finding = { file: string; line: number; kind: string }
15
16type Segment = { text: string; tokens: string[] }
17
18// Splits a command on && || ; | and newlines outside quotes, and each part into unquoted words.
19function segments(cmd: string): Segment[] {
20 const out: Segment[] = []
21 let tokens: string[] = []
22 let word = ''
23 let hasWord = false
24 let text = ''
25 let quote = ''
26 const endWord = () => {
27 if (hasWord) tokens.push(word)
28 word = ''
29 hasWord = false
30 }
31 const endSegment = () => {
32 endWord()
33 if (tokens.length > 0) out.push({ text: text.trim(), tokens })
34 tokens = []
35 text = ''
36 }
37 for (let i = 0; i < cmd.length; i++) {
38 const c = cmd[i] as string
39 if (quote) {
40 if (c === quote) quote = ''
41 else word += c
42 text += c
43 continue
44 }
45 if (c === '"' || c === "'") {
46 quote = c
47 hasWord = true
48 text += c
49 } else if (c === '&' || c === '|' || c === ';' || c === '\n') {
50 endSegment()
51 if ((c === '&' || c === '|') && cmd[i + 1] === c) i++
52 } else if (c === ' ' || c === '\t') {
53 endWord()
54 text += c
55 } else {
56 word += c
57 hasWord = true
58 text += c
59 }
60 }
61 endSegment()
62 return out
63}
64
65type GitCall = { sub: string; gitDir?: string; rest: string[] }
66
67// `git [-C dir] [-c k=v] [flags] <sub> ...`, only when git is the segment's command.
68function gitCall(tokens: string[]): GitCall | null {
69 let i = 0
70 while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] as string)) i++
71 if (tokens[i] !== 'git') return null
72 i++
73 let gitDir: string | undefined
74 while (i < tokens.length && (tokens[i] as string).startsWith('-')) {
75 if (tokens[i] === '-C') gitDir = tokens[i + 1]
76 i += tokens[i] === '-C' || tokens[i] === '-c' ? 2 : 1
77 }
78 const sub = tokens[i]
79 if (sub === undefined) return null
80 return { sub, gitDir, rest: tokens.slice(i + 1) }
81}
82
83// Every git push in the command. Errs on the safe side: any `git ... push` the parser
84// cannot place (inside `bash -c`, after echo) still counts, with the defaults.
85export function parsePushes(cmd: string): Push[] {
86 const pushes: Push[] = []
87 let chdir: string | undefined
88 let staged = false
89 for (const seg of segments(cmd)) {
90 const first = seg.tokens[0]?.replace(/^[({]+/, '')
91 if (first === 'cd' && seg.tokens[1] !== undefined && seg.tokens[1] !== '-') {
92 chdir = seg.tokens[1]
93 continue
94 }
95 const call = gitCall(seg.tokens.map((t, i) => (i === 0 ? t.replace(/^[({]+/, '') : t)))
96 if (!call) continue
97 if (call.sub === 'add' || call.sub === 'commit') staged = true
98 if (call.sub === 'push') {
99 const remote = call.rest.find((t) => !t.startsWith('-')) ?? 'origin'
100 pushes.push({ chdir, gitDir: call.gitDir, remote, staged })
101 }
102 }
103 if (pushes.length === 0 && /\bgit\b[^;&|\n]*\bpush\b/.test(cmd)) {
104 pushes.push({ remote: 'origin', staged: /\bgit\b[^;&|\n]*\b(?:add|commit)\b/.test(cmd) })
105 }
106 return pushes
107}
108
109// A form `gh repo view` reads: git@host:o/r and ssh://git@host/o/r become https URLs.
110export function repoSpec(url: string): string {
111 const u = url.trim()
112 const scp = /^[\w.-]+@([\w.-]+):(.+?)(?:\.git)?\/?$/.exec(u)
113 if (scp) return `https://${scp[1]}/${scp[2]}`
114 const ssh = /^ssh:\/\/(?:[\w.-]+@)?([\w.-]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/.exec(u)
115 if (ssh) return `https://${ssh[1]}/${ssh[2]}`
116 return u.replace(/\.git$/, '')
117}
118
119export type Added = { file: string; line: number; text: string }
120
121// The added lines of a unified diff or a `git log -p` stream, with their line in the new file.
122export function addedLines(diff: string): Added[] {
123 const out: Added[] = []
124 let file = ''
125 let line = 0
126 let inHunk = false
127 for (const raw of diff.split('\n')) {
128 if (raw.startsWith('diff --git ')) {
129 inHunk = false
130 file = ''
131 } else if (!inHunk && raw.startsWith('+++ ')) {
132 const p = raw.slice(4)
133 file = p === '/dev/null' ? '' : p.replace(/^b\//, '')
134 } else if (raw.startsWith('@@')) {
135 const m = /\+(\d+)/.exec(raw)
136 line = m ? Number(m[1]) : 1
137 inHunk = true
138 } else if (inHunk && raw.startsWith('+')) {
139 out.push({ file, line, text: raw.slice(1) })
140 line++
141 } else if (inHunk && raw.startsWith(' ')) {
142 line++
143 }
144 }
145 return out
146}
147
148const HOME_PATH = /(?:\/home|\/Users)\/([A-Za-z0-9._-]+)(?=\/)/g
149const IGNORED_HOME_USERS = new Set(['user', 'runner', 'public', 'default', 'shared'])
150// <drive>:\Users\<name>\, <drive>:/Users/<name>/ and the JSON-escaped <drive>:\\Users\\<name>\\.
151const WINDOWS_HOME = /[A-Za-z]:(?:\\\\|\\|\/)Users(?:\\\\|\\|\/)([^\\/"'\r\n]+)(?=\\|\/)/gi
152const IGNORED_WINDOWS_USERS = new Set(['public', 'default', 'default user', 'all users'])
153const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
154
155// Secrets and home paths in the added lines. Lines of one file are joined so that a
156// multi-line private key block is seen whole. Never returns a secret value.
157export function scanAdded(added: Added[], homes: Array<string | undefined> = []): Finding[] {
158 const findings: Finding[] = []
159 const byFile = new Map<string, Added[]>()
160 for (const a of added) byFile.set(a.file, [...(byFile.get(a.file) ?? []), a])
161 const homeRes = homes
162 .filter((h): h is string => h !== undefined && h.length > 1)
163 .map((h) => new RegExp(`${escape(h.replace(/[\\/]+$/, ''))}(?![A-Za-z0-9._-])`))
164 for (const [file, lines] of byFile) {
165 const starts: number[] = []
166 let text = ''
167 for (const l of lines) {
168 starts.push(text.length)
169 text += l.text + '\n'
170 }
171 const lineAt = (offset: number): number => {
172 let i = 0
173 while (i + 1 < starts.length && (starts[i + 1] as number) <= offset) i++
174 return (lines[i] as Added).line
175 }
176 for (const s of findSecrets(text)) findings.push({ file, line: lineAt(s.start), kind: s.kind })
177 for (const l of lines) {
178 const homeHit =
179 homeRes.some((re) => re.test(l.text)) ||
180 [...l.text.matchAll(HOME_PATH)].some((m) => !IGNORED_HOME_USERS.has((m[1] as string).toLowerCase())) ||
181 [...l.text.matchAll(WINDOWS_HOME)].some((m) => !IGNORED_WINDOWS_USERS.has((m[1] as string).toLowerCase()))
182 if (homeHit) findings.push({ file, line: l.line, kind: 'home_path' })
183 }
184 }
185 return findings
186}
187
188export function denyText(findings: Finding[], visibilityKnown: boolean): string {
189 const shown = findings.slice(0, 10).map((f) => `${f.file}:${f.line} ${f.kind}`)
190 const more = findings.length > 10 ? [`and ${findings.length - 10} more`] : []
191 const why = visibilityKnown ? '' : ' (repo visibility unknown, scanned to be safe)'
192 return [
193 `secret-guard: push blocked, ${findings.length} finding${findings.length === 1 ? '' : 's'}${why}.`,
194 ...shown,
195 ...more,
196 'If these are false positives, ask the user to run /secret-guard allow, then push again.',
197 ].join('\n')
198}
199
200const MAX_FILE_BYTES = 1024 * 1024
201
202// Whole-file "added lines" of an untracked file; none for a binary or a file over 1 MB.
203export function fileAdded(file: string, text: string): Added[] {
204 if (text.length > MAX_FILE_BYTES || text.includes('\0')) return []
205 return text.split('\n').map((t, i) => ({ file, line: i + 1, text: t }))
206}
207types/index.d.ts 13 lines1export type Guard = {
2 /** Values masked in tool results this session. */
3 masked: number
4 /** Epoch ms until which the next push skips the scan; 0 when no pass is active. */
5 passUntil: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'secret-guard': { guard: Guard }
11 }
12}
13