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/docs/mods.md b/docs/mods.md index 3743a1b..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 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, 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/.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..3b3617b 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)) + // 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. @@ -222,7 +223,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 +238,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 +282,14 @@ 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 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) @@ -341,6 +349,78 @@ export function bare(command: string) { .replace(/"(?:[^"\\]|\\.)*"/g, fill) } +// 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[] { + 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)] + // 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 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 (!ADD_SAFE_OPTS.has(w)) return [] + continue + } + // 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) + } + } + 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 + // 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] + }) + 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 && !words[1].startsWith('-') // `cd -` goes to $OLDPWD + 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 ff88991..e348250 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. @@ -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 @@ -76,7 +77,12 @@ 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) : [] + 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: '' } } + } 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 +256,85 @@ 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 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', + '{ 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) + 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 f5ecef5..1e62e33 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.
@@ -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.