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

<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>
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.
| Runs | When a session starts, and when Claude enters a worktree |
| Needs | gitleaks, plus pre-commit for projects without a package.json |
| Writes | .pre-commit-config.yaml, or .husky/pre-commit in JS/TS projects |
| Never | Rewrites 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.
.pre-commit-config.yaml that runs your installed gitleaks, then runs pre-commit install..husky/pre-commit, ahead of existing commands, and turns Husky on..pre-commit-config.yaml without gitleaks, or a core.hooksPath set by another tool, gets a notice instead of a change.package.json only when you turn on autoInstallHusky.prepare script added by secret-guard is husky || true, so installs without dev dependencies do not fail.| Project | What secret-guard does |
|---|---|
Not a Git repository, or gitleaks not installed | Nothing |
No package.json and no .pre-commit-config.yaml | Creates the config and runs pre-commit install |
.pre-commit-config.yaml with gitleaks, hook not installed | Runs pre-commit install |
Hook installed but no .pre-commit-config.yaml, as in a new worktree | Creates the config, so commits are no longer blocked |
Set up, but the config or .husky/pre-commit is not committed | Shows a notice with the git add command |
.pre-commit-config.yaml without gitleaks | Shows a notice and leaves the file as it is |
package.json with Husky as a dependency | Adds gitleaks to .husky/pre-commit and turns Husky on |
package.json without Husky | Shows the install command, or installs Husky when autoInstallHusky is on |
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
y to add the github:AnnihilationWizard/secret-guard marketplace.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" }
}
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.
| Option | Default | Effect |
|---|---|---|
autoInstallHusky | false | In 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.
cd is not detected; commit the config once and every worktree has it.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.core.hooksPath, so hooks in .git/hooks stop running in that project.node_modules/.bin/husky. secret-guard writes the hook and asks you to turn Husky on./plugin install command is available in terminal sessions only. A user-scope install also loads in the desktop app's Code tab.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.
.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
Bug reports and ideas are welcome. Read CONTRIBUTING.md before opening an issue or pull request, and follow SECURITY.md for security reports.
MIT License. Copyright © 2026 AnnihilationWizard.
hooks/register.ts 54 lines1import 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}
54hooks/guard.ts 284 lines1import 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