SLOPSHOPPER

secret-guard

Keeps API keys, tokens and passwords out of the transcript, tool output, files, commits, PRs and issues.

newbandguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-06hagaybar/claude-secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ 🛡 secret-guard blocked Bash: cat would │ ⎿ Read 6 lines │ print .env, a file that holds secrets │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-guard: secret-guard blocked this Bash call because it could expose a secret: - [secret-fi ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ secret-guard: 🛡 Secret guard is ON, block mode. ⎿ secret-guard: Secret variables known by name: 0 (values kept in memory only). ⎿ secret-guard: This session: 1 blocked, 0 flagged, 0 redacted. ⎿ secret-guard: Watching: secret files, env dumps, echoed/argv secrets, file writes, every outbound tool call, git add/commit/ ⎿ secret-guard: Options: /guard log · /guard block|warn · /guard full|compact|off · more in /config. ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🛡 SECRET GUARD ON mode BLOCK [–] │ │ watching: secret files · env · echo/argv · writes · outbound calls · git · gh · output │ │ 0 secret vars known · 1 blocked · 0 flagged · 0 redacted │ │ last: blocked Bash — cat would print .env, a file that holds secrets │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: 🛡 guard on · 1 blocked

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🛡 SECRET GUARD ON mode BLOCK [–] │ │ watching: secret files · env · echo/argv · writes · outbound calls · git · gh · output │ │ 0 secret vars known · 1 blocked · 0 flagged · 0 redacted │ │ last: blocked Bash — cat would print .env, a file that holds secrets │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

🛡 secret-guard

A Claude Code mod that keeps API keys, tokens and passwords out of the agent's reach — enforced by code on your machine, not by instructions in a prompt.

It watches every tool call Claude makes and every row written to the conversation, and refuses (or flags) anything that would print, store, commit, push or send a secret.

📖 See how it decides — interactive flow diagram


⚠️ First draft — read this before relying on it

This is a first attempt at making secret handling local and rigorous by code, not by prompt. Instructions in a CLAUDE.md ask the model to behave; a hook runs on every tool call whether the model remembers the instruction or not. This mod moves those rules into code that runs on this machine.

It is best effort, not a guarantee:

  • The checks are pattern lists (file paths, command shapes, token formats). A command written in a form they don't anticipate — an alias, a script file, eval, unusual quoting — can get past them.
  • Secret values are recognised only for environment variables whose names match secretNames, and only those present when the session started.
  • Secret-shaped strings are recognised only for the token formats listed below.
  • Nothing it does can remove a value that already reached a transcript, a commit or a remote. If a secret leaks, rotate it.

Treat it as a seatbelt alongside careful habits, not as a replacement for them. Bypass reports and ideas are welcome as issues.


Why

An agent with a shell can leak a secret in many small ways: cat ~/.bashrc to "check a variable", echo $API_KEY to debug, ${TOKEN:-default} in a test, a key pasted into a config file, a commit, a PR body. Each lands in the transcript, and once there it cannot be scrubbed — the only fix is rotating the key.

The usual defence is a rule in the prompt. Prompts are advice: they can be forgotten, summarised away, or out-argued. secret-guard turns the same rules into Claude Code function hooks that run on every call, locally, before anything executes.

What it does

LayerWhenWhat happens
KnowSession startReads the environment once and keeps the values of variables whose names look secret (…_KEY, …_TOKEN, SECRET, PASSWORD, AUTH, …). In memory only — never displayed, logged or stored.
GuardBefore every tool callInspects what the call would do. A risk → block (or flag in warn mode) with the reason and a safe alternative. No risk → it runs.
ScrubBefore every row is storedReplaces known values and token-shaped strings in tool output, replies and prompts with [redacted:NAME].
ShowAlwaysA banner above the prompt, a status-line entry, a toast per event, a note on each blocked call, and /guard.

What gets blocked

RuleStopsDo this instead
secret-file-readcat / grep / sed / cp / … or Read on .bashrc, .zshrc, .profile, .env*, ~/.aws/*, .netrc, secrets/, credentials/, .ssh/, .gnupg/, .psst/`grep -nE '^export NAME=' FILE \sed -E 's/=.*/=<redacted>/'`
secret-file-writeWrite over a secret fileHand the user the line with a <placeholder>
env-dumpenv, printenv, set, export -p printing values`env \cut -d= -f1 \sort`
printenv-secretprintenv API_KEY to the screen`printenv API_KEY \sha256sum \cut -c1-12`
echo-secretecho $API_KEY[ -n "$API_KEY" ] && echo set, ${API_KEY:+set}, ${#API_KEY}
default-expansion${API_KEY:-x}, ${API_KEY:=x} (they expand to the value)${API_KEY:+set}
secret-on-argvcurl -H "Authorization: Bearer $TOKEN" (visible in ps and logs)`printenv TOKEN \cmd --stdin, cmd <<< "$TOKEN"`
git-add-secret-fileStaging a secret file, explicitly or through git add -A / .—
commit:*A commit whose staged diff adds a known value or a token shape—
push:*A push of commits (not yet on any remote) that add one—
gh:*gh pr / issue / release / gist text or body files holding one—
secret-valueA real value anywhere in a command, a file write, or any tool's input (MCP tools included)Refer to the variable by name
secret-shapeA string that looks like a token, even an unknown oneUse a <placeholder>

Token shapes recognised: AWS access keys · GitHub tokens and fine-grained PATs · Anthropic keys · OpenAI-style keys · Slack tokens · Google API keys · Ex Libris (Alma) API keys · PEM private-key headers · JWTs · password = "…"-style hard-coded credentials.

What it looks like

When Claude tries echo "$ALMA_API_KEY", the call never runs. Claude receives:

secret-guard blocked this Bash call because it could expose a secret:
- [echo-secret] echo would print the value of ALMA_API_KEY
  (instead: show presence only: [ -n "$NAME" ] && echo set || echo unset)
Do not retry it in another form. If the user needs the value checked, let them do it in their own terminal.

and you see a toast, a note on the call, and the banner above the prompt:

╭──────────────────────────────────────────────────────────────────╮
│ 🛡  SECRET GUARD  ON   mode BLOCK                             [–] │
│ watching: secret files · env · echo/argv · writes · outbound …   │
│ 12 secret vars known · 1 blocked · 0 flagged · 0 redacted        │
│ last: blocked Bash — echo would print the value of ALMA_API_KEY  │
╰──────────────────────────────────────────────────────────────────╯

Install

In a Claude Code terminal session:

/plugin install secret-guard --marketplace hagaybar/claude-secret-guard

Answer y to add the marketplace, then choose the user scope so it loads in every session. Requires a Claude Code build with function-hook mods; tested on 2.1.291.

To work on it locally instead:

git clone https://github.com/hagaybar/claude-secret-guard ~/claude-mods/secret-guard
claude plugin marketplace add ~/claude-mods/secret-guard
claude plugin install secret-guard@hagay-mods --scope user

Edits to that folder take effect with /reload-plugins.

Use

CommandDoes
/guardStatus: mode, secret variables known (count only), this session's blocks / flags / redactions
/guard logThe last 20 events across sessions
/guard block · /guard warnRefuse risky calls, or let them run and only declare them
/guard full · /guard compact · /guard offBanner style

Settings

All in /config (or /plugin configure secret-guard@hagay-mods):

SettingDefaultMeaning
modeblockblock refuses a risky call; warn runs it and declares it
bannerfullfull, compact or off
secretNames`(^\_)(KEY\APIKEY\TOKEN\SECRET\PASSWORD\PASSWD\PASSPHRASE\CREDENTIALS?\AUTH)(_\$)`Regex (case-insensitive) for variable names whose values are secrets
extraForbiddenPaths(empty)Comma-separated globs added to the built-in list, e.g. config/prod.yaml,**/*.pem
allowPaths.env.example,.env.sample,.env.templateGlobs never treated as secret files
redactOutputtrueScrub values and token shapes from stored rows
guardGittrueScan staged changes, outgoing commits and gh bodies
loadEnvValuestrueLearn real values at session start (in memory only)

How it works

Three hooks do the work (see the flow diagram):

  • session.start — runs env once through Claude Code's process API, keeps matching values in a module variable, registers /guard.
  • tool.call — routes by tool: Bash gets the shell rules plus a scan of the command, and for git add / commit / push / gh it runs git status, git diff --cached or git log … --not --remotes and scans the added lines. Read / Write / Edit check the path and the new content. Every other tool (MCP, WebFetch, Agent, …) has all its text inputs scanned. The hook has a .catch that fails closed in block mode: if the check itself crashes, the call is held back (/guard warn is the escape hatch).
  • session.append — rewrites text and tool-result blocks before they are stored.

The display is a ui.render hook on the band above the prompt, plus $.ui.status, $.ui.toast and $.ui.notice.

Files

.claude-plugin/plugin.json        manifest + userConfig settings
.claude-plugin/marketplace.json   makes this repo installable
hooks/hooks.json                  points at the hooks module
hooks/register.tsx                the hooks: start, tool.call, append, /guard, banner
hooks/rules.ts                    pure detection logic (no engine calls) — the rules live here
types/index.d.ts                  the mod's state contract
tests/guard.test.ts               15 behaviour tests (fake values only)
docs/index.html                   the flow diagram (GitHub Pages)

Develop

claude plugin validate .
claude plugin test .

The tests cover each rule family, warn mode, extra forbidden paths, the banner on terminal and desktop surfaces, and redaction. They use fake values only.

To add a token format, add an entry to SHAPES in hooks/rules.ts and a test.

Known gaps

  • Commands in forms the patterns don't anticipate (aliases, script files, eval, unusual quoting) can pass.
  • Only variables present at session start, with secret-looking names, are known by value.
  • Git checks run in the session folder, or one named by a leading cd / git -C. In a single command like git add x && git commit, the commit is checked against what was staged before the command ran.
  • Redaction can't reach anything already stored, committed or pushed.
  • Reading the environment means the values sit in the mod's memory for the session. They never leave it, but turn loadEnvValues off if you'd rather they weren't read at all (token-shape detection still works).

Background

Built while working on Ex Libris Alma integrations at Tel Aviv University Libraries, after seeing how many quiet paths can carry a key into an agent transcript. The rules mirror a "strict secret-handling mode" that had been living in a CLAUDE.md — this is the attempt to make them hold without depending on the model remembering them.

Source 3 files
hooks/register.tsx 375 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardEvent } from '../types'
5import {
6  addedLines,
7  checkBash,
8  dedupe,
9  gitIntents,
10  isForbiddenPath,
11  makeConfig,
12  redact,
13  scanText,
14} from './rules'
15import type { Finding, GitIntent, Known } from './rules'
16
17type $ = EngineInterface
18
19const events = atom({ plugin: 'secret-guard', key: 'events' } as const, [] as GuardEvent[])
20const knownCount = atom({ plugin: 'secret-guard', key: 'knownCount' } as const, 0)
21const isCompact = atom({ plugin: 'secret-guard', key: 'isCompact' } as const, false)
22
23const SHIELD = '\u{1F6E1}'
24const LOG_KEY = 'log'
25
26type Settings = {
27  mode: 'block' | 'warn'
28  banner: string
29  redactOutput: boolean
30  guardGit: boolean
31  loadEnvValues: boolean
32  cfg: ReturnType<typeof makeConfig>
33}
34
35let S: Settings = settingsOf({})
36// The real values live in this module's memory only: never in state, the
37// store, a log line, a toast or anything the model reads.
38let known: Known[] = []
39let loading: Promise<void> | undefined
40
41function settingsOf(options: Record<string, unknown>): Settings {
42  return {
43    mode: options.mode === 'warn' ? 'warn' : 'block',
44    banner: String(options.banner ?? 'full'),
45    redactOutput: options.redactOutput !== false,
46    guardGit: options.guardGit !== false,
47    loadEnvValues: options.loadEnvValues !== false,
48    cfg: makeConfig({
49      secretNames: String(options.secretNames ?? ''),
50      extraForbiddenPaths: String(options.extraForbiddenPaths ?? ''),
51      allowPaths: String(options.allowPaths ?? ''),
52    }),
53  }
54}
55
56async function loadKnown($: $): Promise<void> {
57  if (!S.loadEnvValues) return
58  let run = await $.process.run(['env', '-0'])
59  let sep = '\u0000'
60  if (run.exitCode !== 0) {
61    run = await $.process.run(['env'])
62    sep = '\n'
63  }
64  known = run.stdout
65    .split(sep)
66    .map(line => {
67      const at = line.indexOf('=')
68      return { name: line.slice(0, at), value: line.slice(at + 1) }
69    })
70    .filter(k => k.name.length > 0 && S.cfg.secretName.test(k.name))
71    .filter(k => k.value.length >= 8 && !k.value.startsWith('/'))
72  await update($, knownCount, () => known.length)
73}
74
75function ready($: $): Promise<void> {
76  loading ??= loadKnown($).catch(() => undefined)
77  return loading
78}
79
80async function record($: $, event: Omit<GuardEvent, 'at'>): Promise<void> {
81  const full: GuardEvent = { ...event, at: await $.clock.now() }
82  const list = await update($, events, all => [...all, full].slice(-50))
83  const blocked = list.filter(x => x.kind === 'blocked').length
84  $.ui.status(`${SHIELD} guard on · ${blocked} blocked`)
85  const verb = event.kind === 'blocked' ? 'blocked' : event.kind === 'warned' ? 'flagged' : 'redacted'
86  $.ui.toast(`${SHIELD} secret-guard ${verb} ${event.tool}: ${event.reason}`, { timeoutMs: 8000 })
87  const log = ((await $.store.get(LOG_KEY)) as GuardEvent[] | undefined) ?? []
88  await $.store.set(LOG_KEY, [...log, full].slice(-200))
89}
90
91// -------------------------------------------------------------- inspection
92
93function strings(value: unknown, out: string[] = []): string[] {
94  if (typeof value === 'string') out.push(value)
95  else if (Array.isArray(value)) value.forEach(v => strings(v, out))
96  else if (value !== null && typeof value === 'object') {
97    for (const [k, v] of Object.entries(value)) {
98      if (k !== 'tool' && k !== 'tool_use_id' && k !== 'consent') strings(v, out)
99    }
100  }
101  return out
102}
103
104function within(base: string, dir: string | undefined): string {
105  if (dir === undefined || dir.startsWith('~')) return base
106  return dir.startsWith('/') ? dir : `${base}/${dir}`
107}
108
109async function git($: $, cwd: string, args: string[]): Promise<string> {
110  const run = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 20000 })
111  return run.exitCode === 0 ? run.stdout : ''
112}
113
114function tagged(list: Finding[], tag: string): Finding[] {
115  return list.map(f => ({ ...f, rule: `${tag}:${f.rule}` }))
116}
117
118async function checkGit($: $, intent: GitIntent, base: string): Promise<Finding[]> {
119  const cfg = S.cfg
120  if (intent.kind === 'gh') {
121    const out: Finding[] = []
122    for (const file of intent.files) {
123      if (isForbiddenPath(file, cfg)) {
124        out.push({ rule: 'gh:secret-file', reason: `gh would upload ${file}, a file that holds secrets` })
125        continue
126      }
127      const text = await $.fs.read(file).catch(() => undefined)
128      if (typeof text === 'string') out.push(...tagged(scanText(text, known, `the GitHub text from ${file}`), 'gh'))
129    }
130    return out
131  }
132
133  const cwd = within(base, intent.cwd)
134  if (intent.kind === 'add-all') {
135    const status = await git($, cwd, ['status', '--porcelain', '--untracked-files=all'])
136    const files = status
137      .split('\n')
138      .filter(l => l.length > 3)
139      .map(l => l.slice(3).split(' -> ').pop() ?? '')
140      .filter(p => isForbiddenPath(p, cfg))
141    return files.length === 0
142      ? []
143      : [{ rule: 'git-add-secret-file', reason: `git add would stage ${files.join(', ')}, files that hold secrets` }]
144  }
145
146  if (intent.kind === 'commit') {
147    const range = intent.all ? ['HEAD'] : ['--cached']
148    const names = (await git($, cwd, ['diff', ...range, '--name-only']))
149      .split('\n')
150      .filter(p => p.length > 0 && isForbiddenPath(p, cfg))
151    const diff = await git($, cwd, ['diff', ...range, '--no-color', '-U0'])
152    const out = tagged(scanText(addedLines(diff), known, 'the commit'), 'commit')
153    if (names.length > 0) {
154      out.push({ rule: 'commit:secret-file', reason: `the commit would include ${names.join(', ')}` })
155    }
156    return out
157  }
158
159  // push: every commit not yet on any remote
160  const log = await git($, cwd, ['log', '-p', '--no-color', '--format=', 'HEAD', '--not', '--remotes'])
161  return tagged(scanText(addedLines(log), known, 'the pushed commits'), 'push')
162}
163
164async function inspect($: $, e: { tool: string } & Record<string, unknown>): Promise<Finding[]> {
165  const cfg = S.cfg
166  const tool = e.tool
167  const path = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
168
169  if (tool === 'Bash' && typeof e.command === 'string') {
170    const out = checkBash(e.command, known, cfg)
171    if (S.guardGit) {
172      const base = await $.session.cwd()
173      for (const intent of gitIntents(e.command)) out.push(...(await checkGit($, intent, base)))
174    }
175    return dedupe(out)
176  }
177  if (tool === 'Read' && isForbiddenPath(path, cfg)) {
178    return [{ rule: 'secret-file-read', reason: `Read would load ${path}, a file that holds secrets`, hint: 'ask the user to check it in their own terminal' }]
179  }
180  if (tool === 'Write' && isForbiddenPath(path, cfg)) {
181    return [{ rule: 'secret-file-write', reason: `Write would overwrite ${path}, a file that holds secrets`, hint: 'give the user the line to add, with a <placeholder> for the value' }]
182  }
183  if ((tool === 'Edit' || tool === 'NotebookEdit') && isForbiddenPath(path, cfg)) {
184    // Structural edits of a secrets file are allowed; carrying a value is not.
185    return scanText(strings([e.old_string, e.new_string]).join('\n'), known, `an edit of ${path}`)
186  }
187  if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') {
188    const text = strings([e.content, e.new_string, e.new_source]).join('\n')
189    return scanText(text, known, `the file ${path}`)
190  }
191  return scanText(strings(e).join('\n'), known, `a call to ${tool}`)
192}
193
194function explain(findings: Finding[]): string {
195  return findings.map(f => `- [${f.rule}] ${f.reason}${f.hint ? ` (instead: ${f.hint})` : ''}`).join('\n')
196}
197
198export const register: Register = (on, options) => {
199  S = settingsOf(options)
200  loading = undefined
201  const { mode, banner, redactOutput, guardGit } = S
202
203  // ------------------------------------------------------------ hooks
204
205  on('session.start', async ($, e, next) => {
206    await ready($)
207    await $.command.register({
208      name: 'guard',
209      description: 'Secret guard: status, log, or set mode (block|warn) and banner (full|compact|off)',
210      argumentHint: '[log | block | warn | full | compact | off]',
211    })
212    $.ui.status(`${SHIELD} guard on · ${mode}`)
213    if (banner === 'off') $.ui.toast(`${SHIELD} Secret guard is on (${mode} mode)`)
214    return next(e)
215  })
216
217  on('tool.call', async ($, e, next) => {
218    await ready($)
219    const findings = await inspect($, e as unknown as { tool: string } & Record<string, unknown>)
220    if (findings.length === 0) return next(e)
221
222    const reason = findings.map(f => f.reason).join('; ')
223    const rule = findings.map(f => f.rule).join(', ')
224    if (mode === 'warn') {
225      await record($, { kind: 'warned', rule, reason, tool: e.tool })
226      if (e.tool_use_id) $.ui.notice(e.tool_use_id, `${SHIELD} secret-guard flagged: ${reason}`)
227      return next(e)
228    }
229    await record($, { kind: 'blocked', rule, reason, tool: e.tool })
230    if (e.tool_use_id) $.ui.notice(e.tool_use_id, `${SHIELD} secret-guard blocked this call`)
231    return {
232      deny:
233        `secret-guard blocked this ${e.tool} call because it could expose a secret:\n${explain(findings)}\n` +
234        'Do not retry it in another form. If the user needs the value checked, let them do it in their own terminal.',
235    }
236  }).catch(($, e, next) =>
237    next.called || mode === 'warn'
238      ? next(e)
239      : { deny: 'secret-guard could not check this call, so it was held back. The user can run /guard warn to let calls through.' },
240  )
241
242  on('session.append', async ($, e, next) => {
243    if (!redactOutput) return next(e)
244    await ready($)
245    let count = 0
246    const scrub = (text: string): string => {
247      const r = redact(text, known)
248      count += r.count
249      return r.text
250    }
251    const content = e.message.content.map(block => {
252      const b = block as unknown as Record<string, unknown>
253      if (b.type === 'text' && typeof b.text === 'string') return { ...b, text: scrub(b.text) }
254      if (b.type === 'tool_result') {
255        const inner = b.content
256        if (typeof inner === 'string') return { ...b, content: scrub(inner) }
257        if (Array.isArray(inner)) {
258          return {
259            ...b,
260            content: inner.map(c =>
261              c && typeof c === 'object' && (c as { type?: string }).type === 'text'
262                ? { ...c, text: scrub(String((c as { text?: string }).text ?? '')) }
263                : c,
264            ),
265          }
266        }
267      }
268      return block
269    })
270    if (count === 0) return next(e)
271    await record($, {
272      kind: 'redacted',
273      rule: 'redact',
274      reason: `${count} secret ${count === 1 ? 'value' : 'values'} scrubbed before it was stored`,
275      tool: e.door,
276    })
277    return next({ ...e, message: { ...e.message, content: content as typeof e.message.content } })
278  }).catch(($, e, next) => next(e))
279
280  on('command.run', { command: 'guard' }, async ($, e) => {
281    const arg = e.args.trim().toLowerCase()
282    const rows = await $.config.list()
283    const keyOf = (field: string) => rows.find(r => new RegExp(`^secret-guard(@[^.]+)?\\.${field}$`).test(r.key))?.key
284
285    if (arg === 'block' || arg === 'warn' || arg === 'full' || arg === 'compact' || arg === 'off') {
286      const field = arg === 'block' || arg === 'warn' ? 'mode' : 'banner'
287      const key = keyOf(field)
288      if (key === undefined) return { text: `Could not find the ${field} setting; change it in /config.` }
289      const set = await $.config.set({ key, value: arg })
290      return { text: set.deny ? `Not changed: ${set.deny}` : `Secret guard ${field} is now ${arg}.` }
291    }
292
293    if (arg === 'log') {
294      const log = ((await $.store.get(LOG_KEY)) as GuardEvent[] | undefined) ?? []
295      if (log.length === 0) return { text: 'Secret guard has nothing on record.' }
296      const lines = log.slice(-20).map(x => `${new Date(x.at).toISOString().slice(0, 16)}  ${x.kind.padEnd(8)} ${x.tool.padEnd(10)} ${x.reason}`)
297      return { text: `Secret guard, last ${lines.length} events (all sessions):\n${lines.join('\n')}` }
298    }
299
300    const list = await read($, events)
301    const n = (k: GuardEvent['kind']) => list.filter(x => x.kind === k).length
302    return {
303      text: [
304        `${SHIELD} Secret guard is ON, ${mode} mode.`,
305        `Secret variables known by name: ${await read($, knownCount)} (values kept in memory only).`,
306        `This session: ${n('blocked')} blocked, ${n('warned')} flagged, ${n('redacted')} redacted.`,
307        `Watching: secret files, env dumps, echoed/argv secrets, file writes, every outbound tool call${guardGit ? ', git add/commit/push, gh bodies' : ''}${redactOutput ? ', stored output' : ''}.`,
308        'Options: /guard log · /guard block|warn · /guard full|compact|off · more in /config.',
309      ].join('\n'),
310    }
311  })
312
313  // ------------------------------------------------------------ banner
314
315  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316    if (banner === 'off' || e.props.hasSurvey) return next(e)
317    const { Box, Text, Button } = $.ui.resolve(e)
318    const list = await read($, events)
319    const count = await read($, knownCount)
320    const compact = banner === 'compact' || (await read($, isCompact))
321    const blocked = list.filter(x => x.kind === 'blocked').length
322    const flagged = list.filter(x => x.kind === 'warned').length
323    const redacted = list.filter(x => x.kind === 'redacted').length
324    const last = list[list.length - 1]
325    const accent = mode === 'block' ? 'success' : 'warning'
326    const lastColor = last?.kind === 'blocked' ? 'error' : last?.kind === 'warned' ? 'warning' : 'suggestion'
327    const width = Math.max(20, e.props.bodyColumns)
328
329    if (compact) {
330      return (
331        <Box flexDirection="row">
332          <Text color={accent} bold>{SHIELD} SECRET GUARD ON</Text>
333          <Text dimColor wrap="truncate-end">
334            {' '}· {mode} · {blocked} blocked · {redacted} redacted
335            {last ? ` · last: ${last.kind} ${last.tool}` : ''}{' '}
336          </Text>
337          <Button key="expand" plain label="[+]" onPress={() => update($, isCompact, () => false)} />
338        </Box>
339      )
340    }
341
342    return (
343      <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1} width={Math.min(width, 100)}>
344        <Box flexDirection="row" justifyContent="space-between">
345          <Text>
346            <Text color={accent} bold>{SHIELD}  SECRET GUARD </Text>
347            <Text backgroundColor={accent} color="inverseText" bold> ON </Text>
348            <Text dimColor>  mode </Text>
349            <Text color={accent} bold>{mode.toUpperCase()}</Text>
350          </Text>
351          <Button key="compact" plain label="[–]" onPress={() => update($, isCompact, () => true)} />
352        </Box>
353        <Text dimColor wrap="truncate-end">
354          watching: secret files · env · echo/argv · writes · outbound calls{guardGit ? ' · git · gh' : ''}{redactOutput ? ' · output' : ''}
355        </Text>
356        <Text wrap="truncate-end">
357          <Text color="suggestion">{count}</Text><Text dimColor> secret vars known · </Text>
358          <Text color={blocked > 0 ? 'error' : undefined} bold={blocked > 0}>{blocked}</Text><Text dimColor> blocked · </Text>
359          <Text color={flagged > 0 ? 'warning' : undefined}>{flagged}</Text><Text dimColor> flagged · </Text>
360          <Text color={redacted > 0 ? 'suggestion' : undefined}>{redacted}</Text><Text dimColor> redacted</Text>
361        </Text>
362        {last ? (
363          <Text wrap="truncate-end">
364            <Text color={lastColor} bold>last: {last.kind} </Text>
365            <Text>{last.tool} </Text>
366            <Text dimColor>— {last.reason}</Text>
367          </Text>
368        ) : (
369          <Text dimColor>nothing blocked yet · /guard for status and options</Text>
370        )}
371      </Box>
372    )
373  })
374}
375
hooks/rules.ts 342 lines
1// Pure detection: no `$`, so every rule is testable on its own.
2// A Finding names the rule and what was at risk, never the secret itself.
3
4export type Finding = { rule: string; reason: string; hint?: string }
5export type Known = { name: string; value: string }
6
7export type Config = {
8  secretName: RegExp
9  forbidden: RegExp[]
10  allowed: RegExp[]
11}
12
13const BUILTIN_FORBIDDEN = [
14  /(^|\/)\.(bashrc|zshrc|profile|bash_profile|bash_aliases|netrc)$/,
15  /(^|\/)\.env(\.[^/]*)?$/,
16  /[^/]\.env$/,
17  /(^|\/)\.aws\/(credentials|config)$/,
18  /(^|\/)(secrets|credentials)\/|(^|\/)(\.psst|\.gnupg|\.ssh)(\/|$)/,
19]
20
21export const DEFAULT_SECRET_NAMES =
22  '(^|_)(KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIALS?|AUTH)(_|$)'
23
24function globToRegex(glob: string): RegExp {
25  const body = glob
26    .replace(/[.+^${}()|[\]\\]/g, '\\$&')
27    .replace(/\*\*\/?/g, '\u0000')
28    .replace(/\*/g, '[^/]*')
29    .replace(/\?/g, '[^/]')
30    .replace(/\u0000/g, '(.*/)?')
31  return new RegExp(`(^|/)${body}(/|$)`)
32}
33
34function globs(list: string): RegExp[] {
35  return list
36    .split(',')
37    .map(one => one.trim())
38    .filter(one => one.length > 0)
39    .map(globToRegex)
40}
41
42export function makeConfig(o: {
43  secretNames?: string
44  extraForbiddenPaths?: string
45  allowPaths?: string
46}): Config {
47  let secretName: RegExp
48  try {
49    secretName = new RegExp(o.secretNames || DEFAULT_SECRET_NAMES, 'i')
50  } catch {
51    secretName = new RegExp(DEFAULT_SECRET_NAMES, 'i')
52  }
53  return {
54    secretName,
55    forbidden: [...BUILTIN_FORBIDDEN, ...globs(o.extraForbiddenPaths ?? '')],
56    allowed: globs(o.allowPaths ?? ''),
57  }
58}
59
60export function isForbiddenPath(path: string, c: Config): boolean {
61  const p = path.replace(/^['"]|['"]$/g, '').replace(/\/+$/, '')
62  if (p.length === 0) return false
63  if (c.allowed.some(r => r.test(p))) return false
64  return c.forbidden.some(r => r.test(p))
65}
66
67// ---------------------------------------------------------------- shapes
68
69const SHAPES: { name: string; re: RegExp }[] = [
70  { name: 'AWS access key', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g },
71  { name: 'GitHub token', re: /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
72  { name: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
73  { name: 'OpenAI-style key', re: /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/g },
74  { name: 'Slack token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
75  { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
76  { name: 'Ex Libris API key', re: /\bl7xx[0-9a-f]{32}\b/g },
77  { name: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
78  { name: 'JWT', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
79  {
80    name: 'hard-coded credential',
81    re: /\b(api[_-]?key|secret|token|passw(or)?d)\b["']?\s*[:=]\s*["'][^"'\s$<{]{12,}["']/gi,
82  },
83]
84
85export function findShapes(text: string): string[] {
86  const hits: string[] = []
87  for (const { name, re } of SHAPES) {
88    re.lastIndex = 0
89    if (re.test(text)) hits.push(name)
90  }
91  return hits
92}
93
94export function findKnown(text: string, known: readonly Known[]): string[] {
95  return known.filter(k => text.includes(k.value)).map(k => k.name)
96}
97
98/** Scan any text bound for somewhere it would persist or leave the machine. */
99export function scanText(text: string, known: readonly Known[], where: string): Finding[] {
100  const out: Finding[] = []
101  const names = findKnown(text, known)
102  if (names.length > 0) {
103    out.push({
104      rule: 'secret-value',
105      reason: `the value of ${names.join(', ')} would appear in ${where}`,
106      hint: 'refer to the variable by name, or pipe it: printenv NAME | cmd --stdin',
107    })
108  }
109  const shapes = findShapes(text)
110  if (shapes.length > 0) {
111    out.push({
112      rule: 'secret-shape',
113      reason: `text shaped like a ${shapes.join(', ')} would appear in ${where}`,
114      hint: 'use a <placeholder> instead of a real-looking value',
115    })
116  }
117  return out
118}
119
120export function redact(text: string, known: readonly Known[]): { text: string; count: number } {
121  let count = 0
122  let out = text
123  // Longest first, so a value that contains another is replaced whole.
124  for (const k of [...known].sort((a, b) => b.value.length - a.value.length)) {
125    if (out.includes(k.value)) {
126      count += out.split(k.value).length - 1
127      out = out.split(k.value).join(`[redacted:${k.name}]`)
128    }
129  }
130  for (const { name, re } of SHAPES) {
131    if (name === 'hard-coded credential') continue
132    re.lastIndex = 0
133    out = out.replace(re, () => {
134      count += 1
135      return `[redacted:${name}]`
136    })
137  }
138  return { text: out, count }
139}
140
141// ---------------------------------------------------------------- bash
142
143/** Split into simple commands, keeping the separator that followed each. */
144export function segments(command: string): { text: string; then: string }[] {
145  const out: { text: string; then: string }[] = []
146  const re = /(\|\||&&|\||;|\n)/g
147  let last = 0
148  let m: RegExpExecArray | null
149  while ((m = re.exec(command)) !== null) {
150    const sep = m[1] ?? ''
151    out.push({ text: command.slice(last, m.index).trim(), then: sep })
152    last = m.index + sep.length
153  }
154  out.push({ text: command.slice(last).trim(), then: '' })
155  return out.filter(s => s.text.length > 0)
156}
157
158export function words(segment: string): string[] {
159  return segment
160    .split(/[\s<>()]+/)
161    .map(w => w.replace(/^['"]+|['"]+$/g, ''))
162    .filter(w => w.length > 0)
163}
164
165const READ_VERB =
166  /^(cat|head|tail|less|more|bat|batcat|grep|egrep|fgrep|rg|ag|sed|awk|gawk|strings|xxd|od|hexdump|base64|nl|tac|cut|sort|uniq|diff|cmp|cp|mv|scp|rsync|tee|vi|vim|nvim|nano|emacs|code|jq|yq|python|python3|node|ruby|perl|php|curl|wget|zip|tar|gzip)$/
167
168const DUMP = /^(env|printenv|set|export|declare\s+-[px]+|export\s+-p|typeset\s+-[px]+)$/
169
170const SAFE_DUMP_FILTER = /cut\s+-d\s*['"]?=['"]?\s+-f\s*1\b|sed\s+-E?\s*['"]s\/=\.\*|grep\s+-c\b|wc\b/
171
172function isRedactingPipeline(rest: string): boolean {
173  return /<redacted>/.test(rest) || SAFE_DUMP_FILTER.test(rest)
174}
175
176export function checkBash(command: string, known: readonly Known[], c: Config): Finding[] {
177  const out: Finding[] = []
178  const segs = segments(command)
179
180  segs.forEach((seg, i) => {
181    const w = words(seg.text)
182    const verb = w[0] ?? ''
183    const rest = segs.slice(i + 1).map(s => s.text).join(' | ')
184    const piped = seg.then === '|'
185
186    // 1. reading a secret file
187    const paths = w.slice(1).filter(x => isForbiddenPath(x, c))
188    if (paths.length > 0 && READ_VERB.test(verb) && !isRedactingPipeline(seg.text + ' ' + rest)) {
189      out.push({
190        rule: 'secret-file-read',
191        reason: `${verb} would print ${paths.join(', ')}, a file that holds secrets`,
192        hint: "check for a name only: grep -nE '^export NAME=' FILE | sed -E 's/=.*/=<redacted>/'",
193      })
194    }
195
196    // 2. dumping the whole environment
197    const dumpOf = w.slice(0, 2).join(' ')
198    if ((DUMP.test(verb) && w.length === 1) || DUMP.test(dumpOf) && w.length === 2) {
199      if (!piped || !isRedactingPipeline(rest)) {
200        out.push({
201          rule: 'env-dump',
202          reason: `${seg.text} would print every variable's value`,
203          hint: 'list names only: env | cut -d= -f1 | sort',
204        })
205      }
206    }
207
208    // 3. printenv NAME of a secret, not piped onward
209    if (verb === 'printenv' && w.length >= 2 && !piped) {
210      const named = w.slice(1).filter(n => c.secretName.test(n))
211      if (named.length > 0) {
212        out.push({
213          rule: 'printenv-secret',
214          reason: `printenv ${named.join(' ')} would print the value`,
215          hint: 'compare digests instead: printenv NAME | sha256sum | cut -c1-12',
216        })
217      }
218    }
219
220    // 4. expanding a secret variable
221    const refs = [...seg.text.matchAll(/\$\{?(#?)([A-Za-z_][A-Za-z0-9_]*)(\}|:[+\-=0]|[-=]|)?/g)]
222    for (const r of refs) {
223      const [whole, hash, name = '', after] = r
224      if (!c.secretName.test(name) || hash === '#') continue
225      if (after === ':+' || after === ':0') continue
226      if (after === ':-' || after === '-' || after === ':=' || after === '=') {
227        out.push({
228          rule: 'default-expansion',
229          reason: `${whole}… expands to the value of ${name} when it is set`,
230          hint: 'test presence with ${NAME:+set} or length with ${#NAME}',
231        })
232        continue
233      }
234      const before = seg.text.slice(0, r.index)
235      if (/<<<\s*["']?$/.test(before)) continue
236      if (/(\[\[?|test)\s+-[nz]\s+["']?$/.test(before)) continue
237      if (/^(export|local|declare|readonly|typeset)\b/.test(verb) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(verb)) {
238        continue
239      }
240      if (/^(echo|printf|print)$/.test(verb)) {
241        out.push({
242          rule: 'echo-secret',
243          reason: `${verb} would print the value of ${name}`,
244          hint: 'show presence only: [ -n "$NAME" ] && echo set || echo unset',
245        })
246      } else {
247        out.push({
248          rule: 'secret-on-argv',
249          reason: `${verb} would receive the value of ${name} on its command line (visible in ps and logs)`,
250          hint: 'pass it on stdin: printenv NAME | cmd --stdin, or cmd <<< "$NAME"',
251        })
252      }
253    }
254
255    // 5. staging a secret file
256    if (verb === 'git' && w.includes('add')) {
257      const staged = w.slice(w.indexOf('add') + 1).filter(x => !x.startsWith('-') && isForbiddenPath(x, c))
258      if (staged.length > 0) {
259        out.push({
260          rule: 'git-add-secret-file',
261          reason: `git add would stage ${staged.join(', ')}, a file that holds secrets`,
262        })
263      }
264    }
265  })
266
267  out.push(...scanText(command, known, 'the command'))
268  return dedupe(out)
269}
270
271export function dedupe(list: Finding[]): Finding[] {
272  const seen = new Set<string>()
273  return list.filter(f => {
274    const k = f.rule + '|' + f.reason
275    if (seen.has(k)) return false
276    seen.add(k)
277    return true
278  })
279}
280
281/** Only the added lines of a unified diff, skipping file headers. */
282export function addedLines(diff: string): string {
283  return diff
284    .split('\n')
285    .filter(l => l.startsWith('+') && !l.startsWith('+++'))
286    .map(l => l.slice(1))
287    .join('\n')
288}
289
290// ---------------------------------------------------------------- git / gh shapes
291
292export type GitIntent =
293  | { kind: 'add-all'; cwd?: string }
294  | { kind: 'commit'; cwd?: string; all: boolean }
295  | { kind: 'push'; cwd?: string }
296  | { kind: 'gh'; files: string[] }
297
298export function gitIntents(command: string): GitIntent[] {
299  const out: GitIntent[] = []
300  let cwd: string | undefined
301  for (const seg of segments(command)) {
302    const w = words(seg.text)
303    if (w[0] === 'cd' && w[1]) cwd = w[1]
304    if (w[0] === 'git') {
305      let i = 1
306      let here = cwd
307      while (i < w.length && (w[i] ?? '').startsWith('-')) {
308        if (w[i] === '-C') {
309          here = w[i + 1]
310          i += 2
311        } else if (w[i] === '-c') {
312          i += 2
313        } else i += 1
314      }
315      const sub = w[i]
316      const args = w.slice(i + 1)
317      if (sub === 'add' && args.some(a => a === '-A' || a === '--all' || a === '.' || a === '-u' || a === '--update')) {
318        out.push({ kind: 'add-all', cwd: here })
319      }
320      if (sub === 'commit') {
321        const all = args.some(a => a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a))
322        out.push({ kind: 'commit', cwd: here, all })
323      }
324      if (sub === 'push') out.push({ kind: 'push', cwd: here })
325    }
326    if (w[0] === 'gh') {
327      const files: string[] = []
328      w.forEach((x, j) => {
329        const nextWord = w[j + 1]
330        if (/^(--body-file|-F|--notes-file|--input)$/.test(x) && nextWord) files.push(nextWord)
331        const eq = /^(--body-file|--notes-file|--input)=(.+)$/.exec(x)
332        if (eq?.[2]) files.push(eq[2])
333      })
334      if (w[1] === 'gist' && w[2] === 'create') {
335        files.push(...w.slice(3).filter(x => !x.startsWith('-')))
336      }
337      out.push({ kind: 'gh', files })
338    }
339  }
340  return out
341}
342
types/index.d.ts 16 lines
1export type GuardEvent = {
2  /** 'blocked' refused the call; 'warned' let it run (warn mode); 'redacted' scrubbed stored text. */
3  kind: 'blocked' | 'warned' | 'redacted'
4  rule: string
5  /** What was at risk, never the secret itself. */
6  reason: string
7  tool: string
8  at: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'secret-guard': { events: GuardEvent[]; knownCount: number; isCompact: boolean }
14  }
15}
16