SLOPSHOPPER

secret-guard

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.

newguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /secret-guard ⎿ secret-guard: secret-guard: 2 values masked this session, no pass is active. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-guard

A Claude Code mod that keeps secrets out of the conversation and out of public repos.

  1. It masks API keys, tokens and passwords in every tool result before Claude reads them.
  2. It blocks a git push that would leak a secret or a home path to a public repo.

Install

/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.

Masking

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:

  • Prefixed tokens: Anthropic, OpenAI, GitHub (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), AWS AKIA, Google AIza, Slack, Stripe, GitLab, Apify, Notion, Hugging Face, Replicate, npm, Telegram bot tokens, JWTs.
  • PEM private key blocks (the whole block).
  • Bearer tokens (Bearer followed by 20 or more token characters).
  • The password in a URL such as postgres://user:REDACTED@host.
  • Assignments: a name that contains 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.

Push check

When a Bash command runs git push, secret-guard checks before it runs:

  1. It finds the repo (git -C <dir>, a leading cd <dir> &&, or the session folder) and the remote (default origin).
  2. It asks 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.
  3. It scans the added lines of every local commit the remote does not have. If the same command also runs git add or git commit, it scans the working tree diff and the untracked files too (not ignored, under 1 MB, not binary).
  4. It looks for the same secrets as masking, plus absolute home paths: your real home folder and any /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.

Commands

CommandWhat it does
/secret-guardShows how many values were masked this session and whether a pass is active.
/secret-guard allowLets 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.

Limits

  • Detection is by pattern. A secret with no known prefix and no telling name (for example a bare random string) is not masked.
  • What the model reads is masked. The engine stores a tool result's structured record (what the screen draws) as made, so the transcript file on disk can still hold the raw output.
  • Rows that are not tool results (your prompts, attachments) are not masked.
  • git log output is cut at 4 MB. A very large unpushed history is only scanned in part.
  • Pushes started outside Claude Code's Bash tool (hooks, scripts it launches) are not seen.
  • A file that must contain the literal marker text cannot be written by Claude. Ask Claude to leave it to you.

No options.

License

MIT

Source 4 files
hooks/register.ts 157 lines
1import { 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}
157
hooks/detect.ts 175 lines
1// 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}
175
hooks/push.ts 207 lines
1// 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}
207
types/index.d.ts 13 lines
1export 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