SLOPSHOPPER

secret-guard

Add a gitleaks pre-commit hook to each Git project you open in Claude Code: pre-commit for most projects, Husky for JS/TS

newguardtoastprocesstimer
v1.0.3MITupdated 2026-10-16AnnihilationWizard/secret-guard
A shopper browsing a rack in a slop shop
README

secret-guard

<strong>Secrets stop at the commit.</strong><br> A Claude Code mod that adds a gitleaks pre-commit hook to each Git project you open.

<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Version-1.0.3-green.svg" alt="Version 1.0.3"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a> <a href="https://code.claude.com/docs"><img src="https://img.shields.io/badge/Platform-Claude%20Code-D97757.svg" alt="Platform: Claude Code"></a> <a href="hooks/register.ts"><img src="https://img.shields.io/badge/Type-Function%20hooks%20mod-6E7681.svg" alt="Function hooks mod"></a> <a href="tests/guard.test.ts"><img src="https://img.shields.io/badge/Tests-22%20passing-3FB950.svg" alt="22 tests passing"></a>

<a href="#features">Features</a> · <a href="#install">Install</a> · <a href="#usage">Usage</a> · <a href="#configuration">Configuration</a> · <a href="#development">Development</a> · <a href="CONTRIBUTING.md">Contributing</a> · <a href="CHANGELOG.md">Changelog</a>

Side-by-side terminal: on the left a fresh clone has no hook and a commit with a GitHub token lands in history; on the right secret-guard sets up a gitleaks hook when Claude Code starts and the same commit is stopped

Illustration of a commit before and after.

Installing gitleaks and pre-commit on your machine does not protect a repository until that repository has a hook config and someone runs pre-commit install. On a fresh clone, that step is easy to forget. secret-guard does it for you: when a Claude Code session starts in a Git project, it checks for a gitleaks pre-commit hook and adds one if it is missing. From then on, a commit that contains an API key, token or private key stops before it reaches history.

RunsWhen a session starts, and when Claude enters a worktree
Needsgitleaks, plus pre-commit for projects without a package.json
Writes.pre-commit-config.yaml, or .husky/pre-commit in JS/TS projects
NeverRewrites an existing config, or adds dependencies unless you opt in

[!NOTE] secret-guard is built on Claude Code's function-hooks mod API, which is in early access and may change between releases.

Features

  • Set up once per project: A project that already has a gitleaks hook is left alone, and the check never delays your first prompt.
  • pre-commit for most projects: Writes a .pre-commit-config.yaml that runs your installed gitleaks, then runs pre-commit install.
  • Husky for JS/TS: Puts the gitleaks step at the top of .husky/pre-commit, ahead of existing commands, and turns Husky on.
  • Worktrees work too: A worktree shares the repository's installed hook but not an uncommitted config, so its commits would be blocked. secret-guard adds the config there, and reminds you to commit it so every worktree and clone has it.
  • Respects your setup: An existing .pre-commit-config.yaml without gitleaks, or a core.hooksPath set by another tool, gets a notice instead of a change.
  • No surprise dependencies: Husky is added to package.json only when you turn on autoInstallHusky.
  • Safe defaults for builds: A prepare script added by secret-guard is husky || true, so installs without dev dependencies do not fail.
ProjectWhat secret-guard does
Not a Git repository, or gitleaks not installedNothing
No package.json and no .pre-commit-config.yamlCreates the config and runs pre-commit install
.pre-commit-config.yaml with gitleaks, hook not installedRuns pre-commit install
Hook installed but no .pre-commit-config.yaml, as in a new worktreeCreates the config, so commits are no longer blocked
Set up, but the config or .husky/pre-commit is not committedShows a notice with the git add command
.pre-commit-config.yaml without gitleaksShows a notice and leaves the file as it is
package.json with Husky as a dependencyAdds gitleaks to .husky/pre-commit and turns Husky on
package.json without HuskyShows the install command, or installs Husky when autoInstallHusky is on

Install

Install the tools secret-guard calls:

brew install gitleaks pre-commit

Then, in a Claude Code terminal session, run:

/plugin install secret-guard --marketplace AnnihilationWizard/secret-guard
  1. Answer y to add the github:AnnihilationWizard/secret-guard marketplace.
  2. Choose a scope. The user scope loads the mod in every session.
  3. Claude Code confirms Installed secret-guard. Plugin is now active. The check runs in each session started after that.

To update later, run claude plugin update, then /reload-plugins.

git clone https://github.com/AnnihilationWizard/secret-guard.git
claude --plugin-dir ./secret-guard

To load a clone in every session, add it to the env block of ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/secret-guard" }
}

Usage

Start Claude Code in a Git project. If the project has no gitleaks hook, a notice says what secret-guard added. Then commit as usual. When gitleaks finds a secret in the staged changes, the commit stops and gitleaks prints the file, line and rule, with the secret redacted.

The pre-commit config secret-guard writes:

repos:
  - repo: local
    hooks:
      - id: gitleaks
        name: gitleaks
        entry: gitleaks git --pre-commit --redact --staged --verbose
        language: system
        pass_filenames: false

With gitleaks older than 8.19, the entry is gitleaks protect --staged --redact --verbose.

To allow a false positive, add its fingerprint to a .gitleaksignore file in the project, or set an allowlist in .gitleaks.toml.

Configuration

OptionDefaultEffect
autoInstallHuskyfalseIn a JS/TS project without Husky, adds it as a dev dependency with the package manager the lockfile points to: pnpm, yarn, bun or npm. Changes package.json and the lockfile.

Change it from the /config menu, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "secret-guard@secret-guard": {
      "options": { "autoInstallHusky": true }
    }
  }
}

In a pnpm workspace root, secret-guard installs with pnpm add -w.

Limitations

  • secret-guard sets up every Git project you open, including repositories you cloned only to read. Each project is set up once, and a notice names the change.
  • A worktree is checked when a session starts in it or Claude enters it with EnterWorktree. Moving into one with a shell cd is not detected; commit the config once and every worktree has it.
  • Teammates need gitleaks too. With the pre-commit config, a commit fails when gitleaks is missing. The Husky step prints a warning and lets the commit through.
  • Husky sets core.hooksPath, so hooks in .git/hooks stop running in that project.
  • Yarn Plug'n'Play projects have no node_modules/.bin/husky. secret-guard writes the hook and asks you to turn Husky on.
  • The /plugin install command is available in terminal sessions only. A user-scope install also loads in the desktop app's Code tab.

Development

claude plugin validate .   # manifest, marketplace file and hooks module
claude plugin test .       # tests/*.test.ts against the engine

After Claude Code first loads the mod, it writes the API types to .claude-plugin/types/ (git-ignored). Type-check against them with TypeScript 5.4 or newer:

npx -p typescript@5 tsc -p .

Run claude --plugin-dir . to try changes. In an interactive session the folder is watched, and saving a file reloads the mod.

Project Structure

.claude-plugin/   plugin.json manifest and marketplace.json
hooks/            hooks.json, the session-start hook and the setup logic
tests/            tests for the setup logic
docs/images/      README illustration

Contributing

Bug reports and ideas are welcome. Read CONTRIBUTING.md before opening an issue or pull request, and follow SECURITY.md for security reports.

License

MIT License. Copyright © 2026 AnnihilationWizard.

Source 2 files
hooks/register.ts 54 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { ensureGuard, hostFrom, projectRoot } from './guard.ts'
3
4// Repositories already checked in this session, so moving around one repository does not repeat a notice.
5const checked = new Set<string>()
6
7async function check($: EngineInterface, cwd: string, autoInstallHusky: boolean): Promise<void> {
8  const host = hostFrom($)
9  const root = await projectRoot(host, cwd)
10  if (root === undefined || checked.has(root)) return
11  checked.add(root)
12  const outcome = await ensureGuard(host, cwd, { autoInstallHusky })
13  // Claude Code already heads each toast with the mod's name, so the text carries no prefix of its own.
14  if (outcome.kind === 'added' || outcome.kind === 'warn') $.ui.toast(outcome.message)
15}
16
17/** Runs the check after the current event, so it never holds up the session. */
18function inBackground($: EngineInterface, cwd: string, autoInstallHusky: boolean): void {
19  $.clock.after(0, () => {
20    void check($, cwd, autoInstallHusky).catch(error => $.ui.toast(`The check failed: ${String(error)}`))
21  })
22}
23
24/** The worktree an EnterWorktree result names, if any. */
25export function worktreePath(result: unknown): string | undefined {
26  const path = (result as { result?: { worktreePath?: unknown } } | undefined)?.result?.worktreePath
27  return typeof path === 'string' && path !== '' ? path : undefined
28}
29
30export const register: Register = (on, options) => {
31  const autoInstallHusky = options.autoInstallHusky === true
32
33  on('session.start', async ($, e, next) => {
34    const started = await next(e)
35    inBackground($, e.cwd, autoInstallHusky)
36    return started
37  })
38
39  // A worktree shares the repository's installed hook but not an uncommitted config, so its commits
40  // would be blocked. Check it before the result goes back, so the config is there when work starts.
41  on('tool.call', { tool: 'EnterWorktree' }, async ($, e, next) => {
42    const result = await next(e)
43    const path = worktreePath(result)
44    if (path !== undefined) {
45      try {
46        await check($, path, autoInstallHusky)
47      } catch (error) {
48        $.ui.toast(`The check failed: ${String(error)}`)
49      }
50    }
51    return result
52  }).catch(($, e, next) => next(e)) // never stand in the way of EnterWorktree: replay its own result
53}
54
hooks/guard.ts 284 lines
1import type { EngineInterface, ProcessRunResult } from 'claude-code'
2
3/** What the mod needs from the host: built from `$` in register, an in-memory fake in tests. */
4export type Host = {
5  /** Resolves undefined when the command cannot start (not installed) or times out. */
6  run: (argv: readonly string[], cwd: string, timeoutMs?: number) => Promise<ProcessRunResult | undefined>
7  exists: (path: string) => Promise<boolean>
8  read: (path: string) => Promise<string>
9  write: (path: string, text: string) => Promise<void>
10}
11
12export type Outcome =
13  | { kind: 'skip'; reason: string }
14  | { kind: 'ok' }
15  | { kind: 'added'; message: string }
16  | { kind: 'warn'; message: string }
17
18export type GuardOptions = { autoInstallHusky: boolean }
19
20const MARK = '# secret-guard'
21
22export const PREPARE_SCRIPT = 'husky || true'
23
24const PRE_COMMIT_CONFIG = '.pre-commit-config.yaml'
25const HUSKY_HOOK = '.husky/pre-commit'
26
27/** Whether `path` (relative to the repository root) is committed or staged. */
28async function isTracked(host: Host, root: string, path: string): Promise<boolean> {
29  return succeeded(await host.run(['git', 'ls-files', '--error-unmatch', '--', path], root))
30}
31
32/** An uncommitted hook file exists only in this folder: other worktrees and fresh clones lack it. */
33const commitHint = (path: string): string =>
34  ` Commit ${path} so other worktrees and clones get it: git add ${path} && git commit`
35
36export function hostFrom($: EngineInterface): Host {
37  return {
38    run: async (argv, cwd, timeoutMs = 30_000) => {
39      try {
40        return await $.process.run(argv, { cwd, timeoutMs })
41      } catch {
42        return undefined
43      }
44    },
45    exists: path => $.fs.exists(path),
46    read: async path => (await $.fs.read(path)) as string,
47    write: (path, text) => $.fs.write(path, text),
48  }
49}
50
51const succeeded = (r: ProcessRunResult | undefined): r is ProcessRunResult =>
52  r !== undefined && r.exitCode === 0
53
54const failure = (r: ProcessRunResult | undefined): string =>
55  (r === undefined ? 'could not run' : r.stderr || r.stdout || `exit ${r.exitCode}`).trim().slice(0, 300)
56
57async function readText(host: Host, path: string): Promise<string | undefined> {
58  return (await host.exists(path)) ? host.read(path) : undefined
59}
60
61/** gitleaks 8.19 introduced `gitleaks git`; older releases only have `protect`. */
62export function scanCommand(versionOutput: string): string {
63  const m = /(\d+)\.(\d+)/.exec(versionOutput)
64  const isLegacy = m !== null && (Number(m[1]) < 8 || (Number(m[1]) === 8 && Number(m[2]) < 19))
65  return isLegacy
66    ? 'gitleaks protect --staged --redact --verbose'
67    : 'gitleaks git --pre-commit --redact --staged --verbose'
68}
69
70export function preCommitConfig(scan: string): string {
71  return `${MARK}: scan staged changes with the installed gitleaks before each commit
72repos:
73  - repo: local
74    hooks:
75      - id: gitleaks
76        name: gitleaks
77        entry: ${scan}
78        language: system
79        pass_filenames: false
80`
81}
82
83export function huskyBlock(scan: string): string {
84  return `${MARK}: scan staged changes for secrets before each commit
85if command -v gitleaks >/dev/null 2>&1; then
86  ${scan} || exit 1
87else
88  echo "gitleaks is not installed; skipping the secret scan (brew install gitleaks)" >&2
89fi
90`
91}
92
93/** Puts the block right after the hook's shebang, so it runs before any other command (an exit included). */
94export function prependBlock(existing: string | undefined, block: string): string {
95  if (existing === undefined || existing.trim() === '') return block
96  const lines = existing.split('\n')
97  if (lines[0]?.startsWith('#!')) {
98    return [lines[0], block.trimEnd(), ...lines.slice(1)].join('\n')
99  }
100  return `${block}${existing}`
101}
102
103type PackageManager = { name: string; add: string[] }
104
105async function packageManager(host: Host, root: string): Promise<PackageManager> {
106  if (await host.exists(`${root}/pnpm-lock.yaml`)) {
107    // pnpm refuses to add to a workspace root unless -w is given.
108    const workspaceRoot = (await host.exists(`${root}/pnpm-workspace.yaml`)) ? ['-w'] : []
109    return { name: 'pnpm', add: ['pnpm', 'add', ...workspaceRoot, '-D', 'husky'] }
110  }
111  if (await host.exists(`${root}/yarn.lock`)) return { name: 'yarn', add: ['yarn', 'add', '-D', 'husky'] }
112  if ((await host.exists(`${root}/bun.lockb`)) || (await host.exists(`${root}/bun.lock`))) {
113    return { name: 'bun', add: ['bun', 'add', '-d', 'husky'] }
114  }
115  return { name: 'npm', add: ['npm', 'install', '--save-dev', 'husky'] }
116}
117
118/**
119 * The core.hooksPath set in the project's own config files (local, worktree, global, system), from
120 * `git config --show-scope --get-all` output. Claude Code runs a mod's git with
121 * `-c core.hooksPath=/dev/null`, which shows up with the `command` scope and applies only to that
122 * one call, so it is ignored.
123 */
124export function configuredHooksPath(showScopeOutput: string): string | undefined {
125  let found: string | undefined
126  for (const line of showScopeOutput.split('\n')) {
127    const tab = line.indexOf('\t')
128    if (tab < 0) continue
129    const scope = line.slice(0, tab)
130    const value = line.slice(tab + 1).trim()
131    if (scope !== 'command' && value !== '') found = value
132  }
133  return found
134}
135
136async function hooksPathSetting(host: Host, root: string): Promise<string | undefined> {
137  const r = await host.run(['git', 'config', '--show-scope', '--get-all', 'core.hooksPath'], root)
138  return succeeded(r) ? configuredHooksPath(r.stdout) : undefined
139}
140
141async function ensurePreCommit(host: Host, root: string, scan: string): Promise<Outcome> {
142  if (!succeeded(await host.run(['pre-commit', '--version'], root))) {
143    return { kind: 'skip', reason: 'pre-commit is not installed' }
144  }
145
146  const configPath = `${root}/.pre-commit-config.yaml`
147  const config = await readText(host, configPath)
148  if (config !== undefined && !/gitleaks/.test(config)) {
149    return {
150      kind: 'warn',
151      message: '.pre-commit-config.yaml has no gitleaks hook. It was left as it is; add the hook yourself.',
152    }
153  }
154
155  const hooksPath = await hooksPathSetting(host, root)
156  if (hooksPath !== undefined) {
157    return {
158      kind: 'warn',
159      message: `core.hooksPath is set to ${hooksPath}, so pre-commit install would refuse to run. Nothing was changed.`,
160    }
161  }
162
163  // The hook lives in the repository's shared hooks folder, so a worktree or clone can have it without the config.
164  const hookFile = await host.run(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'hooks/pre-commit'], root)
165  const installed = succeeded(hookFile) ? await readText(host, hookFile.stdout.trim()) : undefined
166  const isInstalled = installed !== undefined && /pre-commit/.test(installed)
167  if (config !== undefined && isInstalled) {
168    return (await isTracked(host, root, PRE_COMMIT_CONFIG))
169      ? { kind: 'ok' }
170      : { kind: 'warn', message: `${PRE_COMMIT_CONFIG} is not committed.${commitHint(PRE_COMMIT_CONFIG)}` }
171  }
172
173  if (config === undefined) await host.write(configPath, preCommitConfig(scan))
174
175  if (!isInstalled) {
176    const install = await host.run(['pre-commit', 'install'], root, 60_000)
177    if (!succeeded(install)) {
178      return { kind: 'warn', message: `pre-commit install failed: ${failure(install)}` }
179    }
180  }
181  const done = config !== undefined
182    ? 'Ran pre-commit install. The gitleaks hook is active.'
183    : isInstalled
184      ? `The pre-commit hook was installed but ${PRE_COMMIT_CONFIG} was missing, so commits were blocked. Created it with gitleaks.`
185      : `Created ${PRE_COMMIT_CONFIG} (gitleaks) and ran pre-commit install.`
186  const hint = (await isTracked(host, root, PRE_COMMIT_CONFIG)) ? '' : commitHint(PRE_COMMIT_CONFIG)
187  return { kind: 'added', message: `${done}${hint}` }
188}
189
190type PackageJson = {
191  dependencies?: Record<string, string>
192  devDependencies?: Record<string, string>
193  scripts?: Record<string, string>
194}
195
196async function ensureHusky(host: Host, root: string, scan: string, options: GuardOptions): Promise<Outcome> {
197  const hookPath = `${root}/.husky/pre-commit`
198  const hook = await readText(host, hookPath)
199  const hooksPath = await hooksPathSetting(host, root)
200  const isHuskyActive = hooksPath !== undefined && /\.husky/.test(hooksPath)
201
202  if (hook !== undefined && /gitleaks/.test(hook)) {
203    if (isHuskyActive) {
204      return (await isTracked(host, root, HUSKY_HOOK))
205        ? { kind: 'ok' }
206        : { kind: 'warn', message: `${HUSKY_HOOK} is not committed.${commitHint(HUSKY_HOOK)}` }
207    }
208    return {
209          kind: 'warn',
210          message: '.husky/pre-commit already runs gitleaks, but Husky is not active. Run the project install, which runs the prepare script.',
211        }
212  }
213
214  let pkg: PackageJson
215  try {
216    pkg = JSON.parse(await host.read(`${root}/package.json`)) as PackageJson
217  } catch {
218    return { kind: 'warn', message: 'package.json could not be parsed. Nothing was changed.' }
219  }
220
221  const pm = await packageManager(host, root)
222  const hasHusky = pkg.devDependencies?.husky !== undefined || pkg.dependencies?.husky !== undefined
223  if (!hasHusky) {
224    if (!options.autoInstallHusky) {
225      return {
226        kind: 'warn',
227        message: `This JS/TS project has no Husky. Run \`${pm.add.join(' ')}\`, or turn on secret-guard's Auto-install Husky option.`,
228      }
229    }
230    const added = await host.run(pm.add, root, 600_000)
231    if (!succeeded(added)) {
232      return { kind: 'warn', message: `Installing Husky with ${pm.name} failed: ${failure(added)}` }
233    }
234  }
235
236  const prepare = pkg.scripts?.prepare
237  if (prepare === undefined) {
238    // `|| true`: installs without dev dependencies (npm ci --omit=dev, Docker) have no husky, and prepare must not fail them.
239    await host.run(['npm', 'pkg', 'set', `scripts.prepare=${PREPARE_SCRIPT}`], root)
240  }
241
242  await host.write(hookPath, prependBlock(hook, huskyBlock(scan)))
243
244  if (!isHuskyActive) {
245    const activated = await host.run([`${root}/node_modules/.bin/husky`], root)
246    if (!succeeded(activated)) {
247      return {
248        kind: 'warn',
249        message: 'Wrote .husky/pre-commit, but could not turn Husky on (are node_modules installed?). Run the project install.',
250      }
251    }
252  }
253
254  const notes = [
255    hasHusky ? undefined : `Installed Husky with ${pm.name}`,
256    prepare === undefined
257      ? 'added a prepare script'
258      : /husky/.test(prepare) ? undefined : `the prepare script does not run husky (${prepare}), so teammates must turn it on after cloning`,
259    'added gitleaks to .husky/pre-commit',
260  ].filter((n): n is string => n !== undefined)
261  const sentence = notes.join('; ')
262  const hint = (await isTracked(host, root, HUSKY_HOOK)) ? '' : commitHint(HUSKY_HOOK)
263  return { kind: 'added', message: `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}.${hint}` }
264}
265
266/** The repository root for `cwd`, or undefined outside a Git repository. */
267export async function projectRoot(host: Host, cwd: string): Promise<string | undefined> {
268  const top = await host.run(['git', 'rev-parse', '--show-toplevel'], cwd)
269  return succeeded(top) ? top.stdout.trim() : undefined
270}
271
272export async function ensureGuard(host: Host, cwd: string, options: GuardOptions): Promise<Outcome> {
273  const gitleaks = await host.run(['gitleaks', 'version'], cwd)
274  if (!succeeded(gitleaks)) return { kind: 'skip', reason: 'gitleaks is not installed' }
275
276  const root = await projectRoot(host, cwd)
277  if (root === undefined) return { kind: 'skip', reason: 'not a Git repository' }
278
279  const scan = scanCommand(gitleaks.stdout)
280  return (await host.exists(`${root}/package.json`))
281    ? ensureHusky(host, root, scan, options)
282    : ensurePreCommit(host, root, scan)
283}
284