From dbab16bdd56cd23843a6a6f3258eaefc47428aeb Mon Sep 17 00:00:00 2001 From: padschneider Date: Mon, 9 Nov 2026 13:05:27 +0100 Subject: [PATCH 1/4] secret-guard: a commit scans only the untracked files its git add names `git add a.ts && git commit` read every untracked file git doesn't ignore. One big untracked file the commit leaves out (a 2.3 MB build report) made every such commit ask "too big to check whole". Now the untracked scan takes the add's pathspecs; `git add .`, `-A`, `-u`, globs and `$vars` still read every untracked file. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- mods/secret-guard/.claude-plugin/plugin.json | 2 +- mods/secret-guard/hooks/register.ts | 39 +++++++++++++++++--- mods/secret-guard/tests/secret-guard.test.ts | 27 +++++++++++++- site/mods/secret-guard/index.html | 4 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 372262f..c62436a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -34,7 +34,7 @@ { "name": "secret-guard", "description": "Keeps API keys and tokens out of Claude's context, your files and your commits: hides them in tool output, and asks before a write, commit or push adds one.", - "version": "0.1.1", + "version": "0.1.2", "source": "./mods/secret-guard", "category": "Guard your repo", "tags": [ diff --git a/mods/secret-guard/.claude-plugin/plugin.json b/mods/secret-guard/.claude-plugin/plugin.json index 8b41f17..33398c9 100644 --- a/mods/secret-guard/.claude-plugin/plugin.json +++ b/mods/secret-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "secret-guard", - "version": "0.1.1", + "version": "0.1.2", "description": "Keeps API keys and tokens out of Claude's context, your files and your commits: hides them in tool output, and asks before a write, commit or push adds one.", "author": { "name": "Hamza Zafar", diff --git a/mods/secret-guard/hooks/register.ts b/mods/secret-guard/hooks/register.ts index da27830..c1ab54c 100644 --- a/mods/secret-guard/hooks/register.ts +++ b/mods/secret-guard/hooks/register.ts @@ -76,7 +76,7 @@ export const register: Register = (on, options) => { const cwd = await runDir($, e.command, c) if (seen.has(`${step} ${cwd}`)) continue seen.add(`${step} ${cwd}`) - let diff = await gitDiff($, cwd, step, GIT_ADD.test(shell)) + let diff = await gitDiff($, cwd, step, GIT_ADD.test(shell) ? addPaths(e.command) : null) // `git init && git add . && git commit`: no repo yet, so git can't answer. The folder's // files are what the first commit takes; a folder the command makes has none yet. // Only when git says the folder is no repo: a timeout in a big repo still asks. @@ -222,7 +222,8 @@ async function ignored($: EngineInterface, path: string) { // then: so a commit scans every change since HEAD (staged or not), and when the command // runs `git add`, the untracked files git doesn't ignore too. It may ask about a change the // commit leaves out; it never misses one the commit takes. -async function gitDiff($: EngineInterface, cwd: string, step: 'commit' | 'push', adds: boolean): Promise { +// adds: the pathspecs the command's `git add` stages ([] = every untracked file), or null when it runs none. +async function gitDiff($: EngineInterface, cwd: string, step: 'commit' | 'push', adds: string[] | null): Promise { const at = { cwd, timeoutMs: 30_000 } const run = (argv: string[]) => $.process.run(argv, at).catch(() => null) const DIFF = [...GIT_SAFE, 'diff', '--no-ext-diff', '--no-color', '-U0'] @@ -236,7 +237,7 @@ async function gitDiff($: EngineInterface, cwd: string, step: 'commit' | 'push', let r = await run([...DIFF, 'HEAD']) if (!r || r.exitCode !== 0) r = await run([...DIFF, '--cached']) // no commit yet if (!r || r.exitCode !== 0) return null - const out = adds ? r.stdout + (await untracked($, cwd)) : r.stdout + const out = adds ? r.stdout + (await untracked($, cwd, adds)) : r.stdout return r.isStdoutTruncated && !out.endsWith(TRUNCATED) ? out + TRUNCATED : out } @@ -280,8 +281,11 @@ async function folderFiles($: EngineInterface, root: string): Promise { } // Untracked files git doesn't ignore, as a diff that adds them whole. At most 200 files of 1 MB. -async function untracked($: EngineInterface, cwd: string): Promise { - const r = await $.process.run([...GIT_SAFE, 'ls-files', '--others', '--exclude-standard', '-z'], { cwd, timeoutMs: 30_000 }).catch(() => null) +// Only those the command's `git add` names: a big build file lying around that the commit +// leaves out must not make every commit "too big to check". +async function untracked($: EngineInterface, cwd: string, paths: string[] = []): Promise { + const spec = paths.length > 0 ? ['--', ...paths] : [] + const r = await $.process.run([...GIT_SAFE, 'ls-files', '--others', '--exclude-standard', '-z', ...spec], { cwd, timeoutMs: 30_000 }).catch(() => null) if (!r || r.exitCode !== 0) return '' let out = '' const files = r.stdout.split('\0').filter(Boolean) @@ -341,6 +345,31 @@ export function bare(command: string) { .replace(/"(?:[^"\\]|\\.)*"/g, fill) } +// The paths every `git add` in the command stages. [] means all untracked files (`-A`, `.`, +// a path we can't read, such as one with `$` or a glob the shell expands): that errs toward scanning more. +export function addPaths(command: string): string[] { + const shell = bare(command) + const out: string[] = [] + const re = new RegExp(`${GIT}add\\b`, 'g') + for (const m of shell.matchAll(re)) { + const from = m.index + m[0].length + const end = shell.slice(from).search(/&&|\|\||[;|\n]/) + const words = [...command.slice(from, end < 0 ? undefined : from + end).matchAll(/(?:"[^"]*"|'[^']*'|[^\s"'])+/g)].map(w => w[0]) + let opts = true + for (const w of words) { + if (opts && w === '--') { opts = false; continue } + if (opts && w.startsWith('-')) { + if (w === '-A' || w === '--all' || w === '--no-ignore-removal') return [] + continue + } + const path = w.replace(/"([^"]*)"|'([^']*)'/g, '$1$2') + if (/[$`*?\[]/.test(w) || path === '.' || path === '') return [] + out.push(path) + } + } + return out +} + // The directory a leading `cd x &&` moves to, if any. export function cdOf(command: string): string | undefined { return /^\s*cd\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(?:&&|;)/.exec(command)?.[1]?.replace(/^["']|["']$/g, '') diff --git a/mods/secret-guard/tests/secret-guard.test.ts b/mods/secret-guard/tests/secret-guard.test.ts index ff88991..6601c12 100644 --- a/mods/secret-guard/tests/secret-guard.test.ts +++ b/mods/secret-guard/tests/secret-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { bare, cdOf, gitCOf } from '../hooks/register' +import { addPaths, bare, cdOf, gitCOf } from '../hooks/register' import { addedLines, builtin, describe as say, mapStrings, redact } from '../hooks/rules' // Fake keys, built at run time so no scanner (GitHub push protection included) sees a key in this file. @@ -76,7 +76,11 @@ function engine(on: any, opts: Opts = {}) { const rows = opts.gitleaks.filter(r => text.includes(r.Secret)) return { value: { exitCode: 0, stdout: JSON.stringify(rows), stderr: '' } } } - if (argv.includes('ls-files')) return { value: { exitCode: 0, stdout: Object.keys(opts.untracked ?? {}).map(f => `${f}\0`).join(''), stderr: '' } } + if (argv.includes('ls-files')) { + const spec = argv.includes('--') ? argv.slice(argv.indexOf('--') + 1) : [] + const names = Object.keys(opts.untracked ?? {}).filter(f => spec.length === 0 || spec.some(p => f === p || f.startsWith(`${p}/`))) + return { value: { exitCode: 0, stdout: names.map(f => `${f}\0`).join(''), stderr: '' } } + } if (argv.includes('rev-parse')) return { value: { exitCode: opts.gitFails && !opts.timedOut ? 128 : 0, stdout: '.git\n', stderr: '' } } if (argv.includes('check-ignore')) return { value: { exitCode: opts.ignored ? 0 : 1, stdout: '', stderr: '' } } if ((argv.includes('diff') || argv.includes('log')) && opts.gitFails) return { value: { exitCode: 128, stdout: '', stderr: 'not a git repository' } } @@ -250,6 +254,25 @@ describe('secret-guard', () => { expect(ran).toEqual([]) }) + test('`git add a.ts && git commit` skips a big untracked file the add leaves out', async ($, on) => { + const { asked, runs } = engine(on, { untracked: { 'a.ts': 'fine\n', 'build/meta.json': 'x'.repeat(1_100_000) } }) + await $.tool.call({ tool: 'Bash', command: 'git add a.ts && git commit -m "a"' } as any) + expect(asked).toEqual([]) + expect(runs.find(a => a.includes('ls-files'))?.slice(-2)).toEqual(['--', 'a.ts']) + }) + + test('`git add .` still reads a big untracked file, and says the commit is too big to check', async ($, on) => { + const { asked } = engine(on, { answer: 'Allow once', untracked: { 'a.ts': 'fine\n', 'build/meta.json': 'x'.repeat(1_100_000) } }) + await $.tool.call({ tool: 'Bash', command: 'git add . && git commit -m "a"' } as any) + expect(asked[0]).toMatch(/too big to check whole/) + }) + + test('addPaths reads the paths each `git add` names', () => { + expect(addPaths('git add a.ts "b c.md" && git commit -m "x . y"')).toEqual(['a.ts', 'b c.md']) + expect(addPaths('git add -- src && git -C . add lib/x.ts; git commit')).toEqual(['src', 'lib/x.ts']) + for (const all of ['git add -A', 'git add .', 'git add --all && git commit', 'git add *.ts', 'git add $F', 'git add -u']) expect(addPaths(all)).toEqual([]) + }) + test('a plain commit leaves untracked files out of the scan', async ($, on) => { const { asked } = engine(on, { untracked: { 'scratch.ts': `k = '${STRIPE}'` } }) await $.tool.call({ tool: 'Bash', command: 'git commit -m "x"' } as any) diff --git a/site/mods/secret-guard/index.html b/site/mods/secret-guard/index.html index f5ecef5..b379bd5 100644 --- a/site/mods/secret-guard/index.html +++ b/site/mods/secret-guard/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

secret-guard

Keeps API keys and tokens out of Claude's context, your files and your commits.

-

Guard your repo Version 0.1.1

+

Guard your repo Version 0.1.2

secret-guard in Claude Code: cat .env shows each key as hidden by secret-guard, and a commit that adds a Stripe key waits for Block it or Allow once
The keys in cat .env never reach Claude, and a commit that adds one waits for you.
From 94462aac72449c411dda7e48b4c636973cc94902 Mon Sep 17 00:00:00 2001 From: padschneider Date: Thu, 12 Nov 2026 16:53:11 +0100 Subject: [PATCH 2/4] secret-guard: narrow the untracked scan only when every git add path is plain Review follow-up. addPaths now fails closed: it narrows only when each word after `add` is a plain relative path or a known-safe option (-v, -f, -N), the add has no global options of its own, nothing comes before `git` in its step, and no cd moves the shell after the start. Escaped spaces, braces, ~, line continuations, pathspec magic, --pathspec-from-file, redirections, -C on the add and a commit's own -C all scan every untracked file, as before. A pathspec git refuses (ls-files exits non-zero) falls back to the full list instead of none. Tests cover each case; docs and site page say what's narrowed. Co-Authored-By: Claude Opus 5.5 --- docs/mods.md | 2 +- mods/secret-guard/hooks/register.ts | 35 +++++++++++++---- mods/secret-guard/tests/secret-guard.test.ts | 41 +++++++++++++++++++- site/mods/secret-guard/index.html | 2 +- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/mods.md b/docs/mods.md index 3743a1b..522e6ab 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -359,7 +359,7 @@ Keeps API keys, tokens and private keys out of Claude's context, your files and - **Tool output:** a key in what a tool returns (`cat .env`, `env`, a file read, an MCP answer) is swapped for `[hidden by secret-guard: GitHub token]` before Claude reads it. The key never reaches the model or the transcript. A toast says what was hidden. If the key can't be cut out cleanly (it's in an error, or in a note another hook added), the whole result is withheld and Claude reads a redacted copy - **Writes:** a Write or Edit that adds a key asks first (Block it / Allow once). A file git ignores, like `.env`, goes through with a toast - **The label stays a label:** a write that would bring `[hidden by secret-guard: …]` into a file is refused, so the real key isn't overwritten. An edit that only keeps a label already there is fine -- **Commits:** `git commit` scans every change since the last commit, plus new files when the same command runs `git add`, and asks if one adds a key. It follows a leading `cd` and each call's own `git -C`, so `git -C a commit && git -C b commit` checks both. Right after `git init` it checks the folder's files instead. A commit or push too big to check whole (over 4 MB, or past 200 new files) asks, since only part of it was read +- **Commits:** `git commit` scans every change since the last commit, plus the new files the same command's `git add` stages, and asks if one adds a key. A plain `git add a.ts b.md` reads only those new files, so a big build file lying around doesn't make every commit too big to check; when the paths can't be read for sure (`.`, `-A`, globs, `$vars`, escapes, `--pathspec-from-file`, a `cd` or `-C` in between), it reads every new file, as before. It follows a leading `cd` and each call's own `git -C`, so `git -C a commit && git -C b commit` checks both. Right after `git init` it checks the folder's files instead. A commit or push too big to check whole (over 4 MB, or past 200 new files) asks, since only part of it was read - **Pushes:** `git push` scans each commit on a local branch that no remote has yet, so a key added and then deleted is still caught - Asks and toasts name the kind of key and the file, never the key - **Scanner:** 18 built-in rules (AWS, GitHub, GitLab, Anthropic, OpenAI, OpenRouter, Stripe, Slack, Google, npm, Hugging Face, SendGrid, JWTs, private keys, passwords in URLs, `SECRET=`/`TOKEN=`/`API_KEY=` lines). With [gitleaks](https://github.com/gitleaks/gitleaks) installed (`brew install gitleaks`), its 150+ rules run too (on tool output up to 512 KB; bigger outputs get the built-in rules). gitleaks runs on your machine from a neutral folder, so a repo's own `.gitleaks.toml` or `gitleaks:allow` comments can't switch it off. Nothing is sent anywhere, and no key is ever checked against its service diff --git a/mods/secret-guard/hooks/register.ts b/mods/secret-guard/hooks/register.ts index c1ab54c..49c4c4e 100644 --- a/mods/secret-guard/hooks/register.ts +++ b/mods/secret-guard/hooks/register.ts @@ -76,7 +76,8 @@ export const register: Register = (on, options) => { const cwd = await runDir($, e.command, c) if (seen.has(`${step} ${cwd}`)) continue seen.add(`${step} ${cwd}`) - let diff = await gitDiff($, cwd, step, GIT_ADD.test(shell) ? addPaths(e.command) : null) + // A commit with its own `git -C` runs elsewhere than the add: scan every untracked file there. + let diff = await gitDiff($, cwd, step, GIT_ADD.test(shell) ? (c ? [] : addPaths(e.command)) : null) // `git init && git add . && git commit`: no repo yet, so git can't answer. The folder's // files are what the first commit takes; a folder the command makes has none yet. // Only when git says the folder is no repo: a timeout in a big repo still asks. @@ -285,7 +286,10 @@ async function folderFiles($: EngineInterface, root: string): Promise { // leaves out must not make every commit "too big to check". async function untracked($: EngineInterface, cwd: string, paths: string[] = []): Promise { const spec = paths.length > 0 ? ['--', ...paths] : [] - const r = await $.process.run([...GIT_SAFE, 'ls-files', '--others', '--exclude-standard', '-z', ...spec], { cwd, timeoutMs: 30_000 }).catch(() => null) + const list = (argv: string[]) => $.process.run([...GIT_SAFE, 'ls-files', '--others', '--exclude-standard', '-z', ...argv], { cwd, timeoutMs: 30_000 }).catch(() => null) + let r = await list(spec) + // A path git won't take (outside the repo, in a submodule) fails the whole list: scan every untracked file instead. + if (spec.length > 0 && (!r || r.exitCode !== 0)) r = await list([]) if (!r || r.exitCode !== 0) return '' let out = '' const files = r.stdout.split('\0').filter(Boolean) @@ -345,25 +349,42 @@ export function bare(command: string) { .replace(/"(?:[^"\\]|\\.)*"/g, fill) } -// The paths every `git add` in the command stages. [] means all untracked files (`-A`, `.`, -// a path we can't read, such as one with `$` or a glob the shell expands): that errs toward scanning more. +// The paths every `git add` in the command stages. [] means all untracked files. It fails +// closed: once the scan covers only these paths, a path read wrong is a file committed +// unscanned. So it narrows only when every word after each `add` is a plain relative path +// or a known-safe option, the `add` has no global options of its own (`git -C sub add`), +// and no `cd` moves the shell after the start. Anything else (`.`, `-A`, `-u`, globs, `$x`, +// `\ ` escapes, `{a,b}`, `~`, `:(magic)`, `--pathspec-from-file`, line breaks, redirections) +// scans every untracked file, as before. +const ADD_SAFE_OPTS = new Set(['-v', '--verbose', '-f', '--force', '-N', '--intent-to-add']) export function addPaths(command: string): string[] { const shell = bare(command) + // A `cd` (or pushd/popd) after the start moves the add away from where the commit runs. + const cds = [...shell.matchAll(/(^|[;&|(\n])\s*(cd|pushd|popd)\b/g)] + // Only a leading `cd x &&` is allowed: the commit follows that one too. + if (cds.length > 1 || (cds.length === 1 && (cds[0][1] !== '' || cdOf(command) === undefined))) return [] + if (/\\\n/.test(command)) return [] const out: string[] = [] const re = new RegExp(`${GIT}add\\b`, 'g') for (const m of shell.matchAll(re)) { + if (!/^git[ \t]+add$/.test(m[0])) return [] + // Nothing before `git` in its step: `GIT_DIR=x git add`, `sudo git add`, `xargs git add` move or hide the paths. + if (/\S/.test(shell.slice(0, m.index).split(/&&|\|\||[;|&\n(]/).pop()!)) return [] const from = m.index + m[0].length - const end = shell.slice(from).search(/&&|\|\||[;|\n]/) + const end = shell.slice(from).search(/&&|\|\||[;|&\n]/) const words = [...command.slice(from, end < 0 ? undefined : from + end).matchAll(/(?:"[^"]*"|'[^']*'|[^\s"'])+/g)].map(w => w[0]) + if (words.length === 0) return [] let opts = true for (const w of words) { if (opts && w === '--') { opts = false; continue } if (opts && w.startsWith('-')) { - if (w === '-A' || w === '--all' || w === '--no-ignore-removal') return [] + if (!ADD_SAFE_OPTS.has(w)) return [] continue } + // Quotes may wrap a plain path ("b c.md"); anything the shell or git would read further may not. + if (/[\\$`*?[\]{}~:<>()!#&=]/.test(w)) return [] const path = w.replace(/"([^"]*)"|'([^']*)'/g, '$1$2') - if (/[$`*?\[]/.test(w) || path === '.' || path === '') return [] + if (path === '' || path === '.' || path.startsWith('/') || path.startsWith('-') || /["']/.test(path)) return [] out.push(path) } } diff --git a/mods/secret-guard/tests/secret-guard.test.ts b/mods/secret-guard/tests/secret-guard.test.ts index 6601c12..74ab2b9 100644 --- a/mods/secret-guard/tests/secret-guard.test.ts +++ b/mods/secret-guard/tests/secret-guard.test.ts @@ -17,6 +17,7 @@ type Opts = { ignored?: boolean // git check-ignore answer gitleaks?: { Secret: string; RuleID: string }[] | null // null: not installed untracked?: Record // untracked files git doesn't ignore, by name + badPathspec?: boolean // git ls-files exits 128 when given paths (one is outside the repo) context?: string[] // what the model reads after the result existing?: string // the file's text before a Write gitFails?: boolean // git diff / log exit non-zero @@ -78,6 +79,7 @@ function engine(on: any, opts: Opts = {}) { } if (argv.includes('ls-files')) { const spec = argv.includes('--') ? argv.slice(argv.indexOf('--') + 1) : [] + if (opts.badPathspec && spec.length > 0) return { value: { exitCode: 128, stdout: '', stderr: 'fatal: outside repository' } } const names = Object.keys(opts.untracked ?? {}).filter(f => spec.length === 0 || spec.some(p => f === p || f.startsWith(`${p}/`))) return { value: { exitCode: 0, stdout: names.map(f => `${f}\0`).join(''), stderr: '' } } } @@ -269,10 +271,47 @@ describe('secret-guard', () => { test('addPaths reads the paths each `git add` names', () => { expect(addPaths('git add a.ts "b c.md" && git commit -m "x . y"')).toEqual(['a.ts', 'b c.md']) - expect(addPaths('git add -- src && git -C . add lib/x.ts; git commit')).toEqual(['src', 'lib/x.ts']) + expect(addPaths('git add -- src && git add -v lib/x.ts; git commit')).toEqual(['src', 'lib/x.ts']) + expect(addPaths('cd app && git add a.ts && git commit')).toEqual(['a.ts']) for (const all of ['git add -A', 'git add .', 'git add --all && git commit', 'git add *.ts', 'git add $F', 'git add -u']) expect(addPaths(all)).toEqual([]) }) + test('addPaths scans every untracked file when it cannot read a path for sure', () => { + for (const all of [ + 'git add config\\ prod.env && git commit', // escaped space + 'git add a.ts --pathspec-from-file=l.txt && git commit', + 'git add a.ts --pathspec-from-file l.txt && git commit', + 'git add --pathspec-file-nul --pathspec-from-file=l.txt && git commit', + 'git -C sub add x.env && git commit', // the add's own -C + 'git --git-dir=../x/.git add a && git commit', + 'git add .env{,.local} && git commit', // brace expansion + 'git add ~/x && git commit', + 'git add a.ts \\\n b.env && git commit', // line continuation + 'git add ":(glob)**/*.env" && git commit', // pathspec magic + 'git add /abs/x.env && git commit', + 'git add a.ts && cd sub && git add b.env && git commit', // a cd after the start + '(cd sub && git add b.env) && git commit', + 'GIT_DIR=../o/.git git add a && git commit', + 'ls | xargs git add && git commit', + 'git add a.ts 2>/dev/null && git commit', + 'git add -p a.ts && git commit', + 'git add && git commit', + ]) expect(addPaths(all)).toEqual([]) + }) + + test('a commit with its own `git -C` scans every untracked file there', async ($, on) => { + const { runs } = engine(on, { untracked: { 'a.ts': 'fine\n' } }) + await $.tool.call({ tool: 'Bash', command: 'git add a.ts && git -C sub commit -m "a"' } as any) + expect(runs.find(a => a.includes('ls-files'))?.includes('--')).toBe(false) + }) + + test('a path git refuses (outside the repo) falls back to every untracked file, not none', async ($, on) => { + const { asked } = engine(on, { answer: 'Block it', badPathspec: true, untracked: { 'x.env': `k = '${STRIPE}'` } }) + const r = await $.tool.call({ tool: 'Bash', command: 'git add ../other/y.ts x.env && git commit -m "a"' } as any) + expect(asked[0]).toMatch(/Stripe/) + expect((r as any).deny).toBeTruthy() + }) + test('a plain commit leaves untracked files out of the scan', async ($, on) => { const { asked } = engine(on, { untracked: { 'scratch.ts': `k = '${STRIPE}'` } }) await $.tool.call({ tool: 'Bash', command: 'git commit -m "x"' } as any) diff --git a/site/mods/secret-guard/index.html b/site/mods/secret-guard/index.html index b379bd5..1e62e33 100644 --- a/site/mods/secret-guard/index.html +++ b/site/mods/secret-guard/index.html @@ -70,7 +70,7 @@

secret-guard

  • A key in what a tool returns (cat .env, env, a file read, an MCP answer) becomes [hidden by secret-guard: GitHub token] before Claude reads it. It never reaches the model or the transcript.
  • A Write or Edit that adds a key asks first. A file git ignores, like .env, goes through with a toast.
  • A write that would bring the label into a file is refused, so the real key isn't overwritten.
  • -
  • git commit scans every change since the last commit, plus new files when the same command runs git add. git push scans each local commit no remote has yet.
  • +
  • git commit scans every change since the last commit, plus the new files the same command's git add stages (every new file when its paths can't be read for sure). git push scans each local commit no remote has yet.
  • Asks and toasts name the kind of key and the file, never the key.
  • gitleaks runs on your machine from a neutral folder, so a repo can't switch it off. Nothing is sent anywhere.
  • A command you run yourself with ! and a file you attach with @ go straight to Claude. A shell command that writes a file isn't checked like a Write.
  • From 162c170e45506a9b6f84555805d5079549fdd96e Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Sat, 14 Nov 2026 16:25:46 +0100 Subject: [PATCH 3/4] secret-guard: narrow the untracked scan only when the whole command is plain git addPaths now narrows only when the command is an optional leading cd plus git add, commit, push and status. A hidden add beside a plain one (git stage, bash -c, eval, a heredoc, "git" add, git -c alias), a cd the parse missed ({ cd x; }, command cd) and a path glued from quoted and bare parts all scan every untracked file again. A quoted-heredoc commit message still narrows. --- docs/mods.md | 2 +- mods/secret-guard/hooks/register.ts | 31 +++++++++++++++++++- mods/secret-guard/tests/secret-guard.test.ts | 21 +++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/mods.md b/docs/mods.md index 522e6ab..31cdf4f 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -359,7 +359,7 @@ Keeps API keys, tokens and private keys out of Claude's context, your files and - **Tool output:** a key in what a tool returns (`cat .env`, `env`, a file read, an MCP answer) is swapped for `[hidden by secret-guard: GitHub token]` before Claude reads it. The key never reaches the model or the transcript. A toast says what was hidden. If the key can't be cut out cleanly (it's in an error, or in a note another hook added), the whole result is withheld and Claude reads a redacted copy - **Writes:** a Write or Edit that adds a key asks first (Block it / Allow once). A file git ignores, like `.env`, goes through with a toast - **The label stays a label:** a write that would bring `[hidden by secret-guard: …]` into a file is refused, so the real key isn't overwritten. An edit that only keeps a label already there is fine -- **Commits:** `git commit` scans every change since the last commit, plus the new files the same command's `git add` stages, and asks if one adds a key. A plain `git add a.ts b.md` reads only those new files, so a big build file lying around doesn't make every commit too big to check; when the paths can't be read for sure (`.`, `-A`, globs, `$vars`, escapes, `--pathspec-from-file`, a `cd` or `-C` in between), it reads every new file, as before. It follows a leading `cd` and each call's own `git -C`, so `git -C a commit && git -C b commit` checks both. Right after `git init` it checks the folder's files instead. A commit or push too big to check whole (over 4 MB, or past 200 new files) asks, since only part of it was read +- **Commits:** `git commit` scans every change since the last commit, plus the new files the same command's `git add` stages, and asks if one adds a key. A plain `git add a.ts b.md` reads only those new files, so a big build file lying around doesn't make every commit too big to check; when the paths can't be read for sure (`.`, `-A`, globs, `$vars`, escapes, `--pathspec-from-file`, a `cd` or `-C` in between, or anything in the command besides a leading `cd` and `git add`/`commit`/`push`/`status`), it reads every new file, as before. It follows a leading `cd` and each call's own `git -C`, so `git -C a commit && git -C b commit` checks both. Right after `git init` it checks the folder's files instead. A commit or push too big to check whole (over 4 MB, or past 200 new files) asks, since only part of it was read - **Pushes:** `git push` scans each commit on a local branch that no remote has yet, so a key added and then deleted is still caught - Asks and toasts name the kind of key and the file, never the key - **Scanner:** 18 built-in rules (AWS, GitHub, GitLab, Anthropic, OpenAI, OpenRouter, Stripe, Slack, Google, npm, Hugging Face, SendGrid, JWTs, private keys, passwords in URLs, `SECRET=`/`TOKEN=`/`API_KEY=` lines). With [gitleaks](https://github.com/gitleaks/gitleaks) installed (`brew install gitleaks`), its 150+ rules run too (on tool output up to 512 KB; bigger outputs get the built-in rules). gitleaks runs on your machine from a neutral folder, so a repo's own `.gitleaks.toml` or `gitleaks:allow` comments can't switch it off. Nothing is sent anywhere, and no key is ever checked against its service diff --git a/mods/secret-guard/hooks/register.ts b/mods/secret-guard/hooks/register.ts index 49c4c4e..32a1688 100644 --- a/mods/secret-guard/hooks/register.ts +++ b/mods/secret-guard/hooks/register.ts @@ -358,6 +358,7 @@ export function bare(command: string) { // scans every untracked file, as before. const ADD_SAFE_OPTS = new Set(['-v', '--verbose', '-f', '--force', '-N', '--intent-to-add']) export function addPaths(command: string): string[] { + if (!plainShape(command)) return [] const shell = bare(command) // A `cd` (or pushd/popd) after the start moves the add away from where the commit runs. const cds = [...shell.matchAll(/(^|[;&|(\n])\s*(cd|pushd|popd)\b/g)] @@ -381,8 +382,10 @@ export function addPaths(command: string): string[] { if (!ADD_SAFE_OPTS.has(w)) return [] continue } - // Quotes may wrap a plain path ("b c.md"); anything the shell or git would read further may not. + // Quotes may wrap a whole plain path ("b c.md"); anything the shell or git would read further may not. if (/[\\$`*?[\]{}~:<>()!#&=]/.test(w)) return [] + // A word glued from quoted and bare parts ('safe'.env) is one path the split above may cut. + if (!/^(?:"[^"]*"|'[^']*'|[^"']*)$/.test(w)) return [] const path = w.replace(/"([^"]*)"|'([^']*)'/g, '$1$2') if (path === '' || path === '.' || path.startsWith('/') || path.startsWith('-') || /["']/.test(path)) return [] out.push(path) @@ -391,6 +394,32 @@ export function addPaths(command: string): string[] { return out } +// Whether the whole command is a shape addPaths can read for sure: an optional leading +// `cd dir`, then only `git add`, `git commit`, `git push` and `git status`, joined by `&&`, +// `;` or line breaks. A commit message may be quoted, or a `"$(cat <<'EOF' … EOF)"` heredoc +// (its delimiter quoted, so nothing in it runs). Anything else may stage or move a file the +// parse never sees: `git stage`, `bash -c "git add x"`, `eval`, `{ cd x; }`, `command cd`, +// `git -c alias.s=add s x`, `"git" add`. +const PLAIN_GIT = new Set(['add', 'commit', 'push', 'status']) +function plainShape(command: string): boolean { + let isQuoteSafe = true + const s = command + .replace(/"\$\(cat <<'(\w+)'\n[\s\S]*?\n\1\n\s*\)"/g, '""') + .replace(/"[^"]*"|'[^']*'/g, q => { + if (q[0] === '"' && /[$`\\!]/.test(q)) isQuoteSafe = false // still read by the shell + return q[0] + q[0] + }) + if (!isQuoteSafe) return false + const steps = s.split(/&&|;|\n/) + if (/[`$\\<>|(){}*?[\]~!#&"']/.test(steps.join(' ').replace(/''|""/g, ''))) return false + return steps.every((step, i) => { + const words = step.trim().split(/[ \t]+/).filter(Boolean) + if (words.length === 0) return true + if (i === 0 && words[0] === 'cd') return words.length === 2 + return words[0] === 'git' && PLAIN_GIT.has(words[1]) + }) +} + // The directory a leading `cd x &&` moves to, if any. export function cdOf(command: string): string | undefined { return /^\s*cd\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(?:&&|;)/.exec(command)?.[1]?.replace(/^["']|["']$/g, '') diff --git a/mods/secret-guard/tests/secret-guard.test.ts b/mods/secret-guard/tests/secret-guard.test.ts index 74ab2b9..f1d33da 100644 --- a/mods/secret-guard/tests/secret-guard.test.ts +++ b/mods/secret-guard/tests/secret-guard.test.ts @@ -296,9 +296,30 @@ describe('secret-guard', () => { 'git add a.ts 2>/dev/null && git commit', 'git add -p a.ts && git commit', 'git add && git commit', + '{ cd sub; git add x.env; } && git commit -m x', // a cd the parse misses + 'command cd sub && git add x.env && git commit -m x', + 'builtin cd sub && git add x.env && git commit -m x', + 'git stage x.env && git add a.ts && git commit -m x', // a hidden add beside a plain one + 'git update-index --add x.env && git add a.ts && git commit -m x', + 'bash -c "git add x.env" && git add a.ts && git commit -m x', + "sh -c 'git add x.env' && git add a.ts && git commit -m x", + 'eval "git add x.env" && git add a.ts && git commit -m x', + 'bash < { + expect(addPaths("git add a.ts b.md && git commit -m \"$(cat <<'EOF'\nAdd a; run `x` with $HOME\nEOF\n)\" && git push")).toEqual(['a.ts', 'b.md']) + expect(addPaths("cd app && git add 'b c.md' && git commit -m 'fix (x) & y' && git status")).toEqual(['b c.md']) + }) + test('a commit with its own `git -C` scans every untracked file there', async ($, on) => { const { runs } = engine(on, { untracked: { 'a.ts': 'fine\n' } }) await $.tool.call({ tool: 'Bash', command: 'git add a.ts && git -C sub commit -m "a"' } as any) From 07e13c039bef302d2a347896e33b5b3ccfd9089e Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Sat, 14 Nov 2026 16:28:24 +0100 Subject: [PATCH 4/4] secret-guard: a commit-message heredoc ends at its first delimiter line; cd - scans everything --- mods/secret-guard/hooks/register.ts | 5 +++-- mods/secret-guard/tests/secret-guard.test.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mods/secret-guard/hooks/register.ts b/mods/secret-guard/hooks/register.ts index 32a1688..3b3617b 100644 --- a/mods/secret-guard/hooks/register.ts +++ b/mods/secret-guard/hooks/register.ts @@ -404,7 +404,8 @@ const PLAIN_GIT = new Set(['add', 'commit', 'push', 'status']) function plainShape(command: string): boolean { let isQuoteSafe = true const s = command - .replace(/"\$\(cat <<'(\w+)'\n[\s\S]*?\n\1\n\s*\)"/g, '""') + // The heredoc ends at the first delimiter line, so no body line may be one. + .replace(/"\$\(cat <<'(\w+)'\n(?:(?!\1\n)[^\n]*\n)*?\1\n\s*\)"/g, '""') .replace(/"[^"]*"|'[^']*'/g, q => { if (q[0] === '"' && /[$`\\!]/.test(q)) isQuoteSafe = false // still read by the shell return q[0] + q[0] @@ -415,7 +416,7 @@ function plainShape(command: string): boolean { return steps.every((step, i) => { const words = step.trim().split(/[ \t]+/).filter(Boolean) if (words.length === 0) return true - if (i === 0 && words[0] === 'cd') return words.length === 2 + if (i === 0 && words[0] === 'cd') return words.length === 2 && !words[1].startsWith('-') // `cd -` goes to $OLDPWD return words[0] === 'git' && PLAIN_GIT.has(words[1]) }) } diff --git a/mods/secret-guard/tests/secret-guard.test.ts b/mods/secret-guard/tests/secret-guard.test.ts index f1d33da..e348250 100644 --- a/mods/secret-guard/tests/secret-guard.test.ts +++ b/mods/secret-guard/tests/secret-guard.test.ts @@ -312,6 +312,8 @@ describe('secret-guard', () => { 'git -c alias.s=add s x.env && git add a.ts && git commit -m x', "git add 'safe'.env && git commit", // one path glued from quoted and bare parts 'git add a.ts && git commit -m "$(cat <