Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mods/secret-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
90 changes: 85 additions & 5 deletions mods/secret-guard/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string | null> {
// 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<string | null> {
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']
Expand All @@ -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
}

Expand Down Expand Up @@ -280,8 +282,14 @@ async function folderFiles($: EngineInterface, root: string): Promise<string> {
}

// 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<string> {
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<string> {
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)
Expand Down Expand Up @@ -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, '')
Expand Down
89 changes: 87 additions & 2 deletions mods/secret-guard/tests/secret-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,6 +17,7 @@ type Opts = {
ignored?: boolean // git check-ignore answer
gitleaks?: { Secret: string; RuleID: string }[] | null // null: not installed
untracked?: Record<string, string> // 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
Expand Down Expand Up @@ -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' } }
Expand Down Expand Up @@ -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 <<EOF\ngit add x.env\nEOF\ngit add a.ts && git commit -m x',
'"git" add x.env && git add a.ts && git commit -m x',
'git"" add x.env && git add a.ts && git commit -m x',
'git ad\\d x.env && git add a.ts && git commit -m x',
'G=git; $G add x.env && git add a.ts && git commit -m x',
'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 <<EOF\n$(git add x.env)\nEOF\n)"', // unquoted heredoc runs
"git add a.ts && git commit -m \"$(cat <<'EOF'\nmsg\nEOF\ngit add x.env\nEOF\n)\"", // the heredoc ends at the first EOF
'cd - && git add x.env && git commit -m x',
]) expect(addPaths(all)).toEqual([])
})

test('addPaths still narrows a commit message written as a quoted heredoc', () => {
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)
Expand Down
Loading
Loading