SLOPSHOPPER

LeakStop

Stops secrets from leaking before they are written, printed or committed to git.

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leakstop
│ ┃ LeakStop ✕ › fix the failing auth test and add an audit log call │ ┃ No findings this session. │ ┃ [ Pause ] [ Close ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Edit(/work/app/src/auth.ts) │ ⎿ Denied by leakstop: LeakStop could not check this call (t │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /leakstop │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LeakStop
No findings this session. [ Pause ] [ Close ]
README

LeakStop

Stops Claude Code from leaking secrets before it does.

When an AI agent works in your terminal it can, without meaning to, copy an API key from .env into your code, print your whole environment into the conversation, or git add . a private key. LeakStop is a Claude Code mod that watches what Claude is about to do and steps in before it happens: it asks you, or blocks the action, and tells Claude how to do it safely (for example, "read the key from an environment variable instead").

It runs entirely on your machine. No network, no AI model, no accounts. It never reads your environment variables, and it never shows or stores a full secret: only a short prefix and the last three characters.

Requirements

Claude Code 2.1.287 or later. LeakStop is a Claude Code mod, a kind of plugin that is on by default from that version.

What it watches

  • Files Claude writes or edits: private keys, and tokens from AWS, GitHub, GitLab, Anthropic, OpenAI, Stripe, Slack, Google, npm and Hugging Face; passwords inside URLs; JWTs; Authorization headers; and password = "…"-style assignments.
  • Commands Claude runs: a literal secret in a command, cat or grep of sensitive files such as .env and private keys (also from git history, git show HEAD:.env, or through find -exec), printenv, and git add . when it would stage a sensitive file that git does not ignore.
  • Commits and pushes: a git commit or git push that would publish a secret is blocked.
  • Files Claude reads: reading a sensitive file puts its contents in the conversation, so that is held too.
  • What tools return: a real secret in what a command, a file read or an MCP tool returns is masked before Claude sees it.
  • Your messages: a real secret you paste into the prompt is masked before it is sent.
  • What Claude sends out: a secret in a web request, a search, a message to another agent or any argument of an MCP tool is held before it leaves the session.

What you see

Most of the time, nothing: LeakStop stays quiet until something looks dangerous. A weaker signal shows a warning line above the prompt. A real secret opens a question with numbered options (anything other than "Allow once" means no). A risky commit or push is denied straight away.

Run /leakstop to see a record of what it found this session, never the secret itself. It also has pause, resume, allow, allowed, forget and reload subcommands.

Protection level

Set the mode option with /plugin configure leakstop@leakstop:

  • standard (default) holds real secrets, blocks risky commits and pushes, masks real secrets in what tools return and in your messages, and warns on weaker signals.
  • strict also holds and masks weaker signals and blocks reading sensitive files outright.
  • monitor never holds, blocks or masks, it only warns and logs. It is the safest way to try LeakStop on a new repository.

Turn on the statusLine option (off by default) to keep a line under the prompt that says LeakStop is on; when it is missing, nothing is protecting the session.

How it works

LeakStop is a hooks module. Each hook looks at what is about to happen, or at what a tool returned, and passes it on unchanged unless it finds a problem.

  • session.start registers the /leakstop command and reads .leakstop.json from the project folder, if there is one.
  • prompt.submit clears the warning line when you send your own message, and masks a real secret pasted into it.
  • command.run, for /leakstop only, answers the subcommands (pause, resume, allow, allowed, forget, reload).
  • ui.render draws the warning line above the prompt and the findings panel.
  • tool.call runs before Write, Edit, NotebookEdit, Bash, Read, the tools that send data out (WebFetch, WebSearch, Agent, SendMessage, SendFile, Artifact and similar) and every MCP tool. It scans the input for secrets and sensitive files and either lets the call go on, asks you, or denies it. For Bash, Read and every MCP tool it then scans what the tool returned and masks real secrets in it.

What it changes

It does not edit a tool's input, with one exception, described below. It does change three things on their way to Claude, and only to replace a secret by its masked form (a short prefix and the last three characters): what Bash, Read and MCP tools return, the text you send, and, for a large Bash output, the copy of it that Claude Code saves under its own folder in your home directory, which LeakStop reads and rewrites with the value masked. It only rewrites that copy when it is a regular file inside a tool-results folder; any other path is left alone.

The exception for inputs: when a Bash command is a single plain view of an environment file (such as cat .env) or of the whole environment (env or printenv), the question offers Show names only. If you choose it, the command is replaced by a sed filter that prints the variable names and hides every value. In every other case the call is either passed on exactly as it came, or denied.

What it runs

LeakStop starts two programs, both read-only and local, with a time limit:

  • git, in the project folder, with fixed arguments: check-ignore (is this file ignored?), ls-files --others --exclude-standard and diff --name-only (what would git add . stage?), diff and diff --cached (what would git commit add?), and log -p over the commits that are not on a remote yet (what would git push publish?). None of these writes anything or uses the network. The path or folder is the only variable part.
  • find, up to eight levels deep, to see whether a recursive grep or rg would reach a sensitive file such as .env or a private key.

It also reads the files that a tool call is about to write, send or open, .leakstop.json, and the saved copy of a large command output, only to scan them. It keeps hashes of what you allow for good (/leakstop allow) in Claude Code's plugin store, and the findings of the session in Claude Code's session state.

What it sends and where

Nothing. LeakStop has no network access, does not call a model and sends no data anywhere. The only things it produces are the question it shows you, the warning line, the findings panel, and the short reason it gives Claude when it denies an action or masks a secret, which only ever shows a masked value.

It never reads your environment variables or any credential from your machine. The names of environment variables appear only inside the advice it gives Claude ("read the value from an environment variable instead").

Detection patterns

The detector keeps lists of program names and variable names to look for in the text of a command, such as the names of script interpreters and of the variables that usually hold keys. It only compares text with them: LeakStop never runs that code, never reads the value of an environment variable and never sends anything out.

Privacy and trust

LeakStop collects nothing and sends nothing. It has no network, model or environment access, and claude plugin validate --strict lists every capability its code uses so you can check that yourself. It is open source under the MIT licence, and everything it needs is readable plain code in the hooks folder of this plugin.

More

The full documentation, a demo recording, the changelog and the privacy and security policies are in the project's repository, linked as the homepage of this plugin.

LeakStop is a safety net, not a wall. The repository README lists what it cannot see.

Source 12 files
hooks/leakstop.tsx 1106 lines
1// LeakStop: the hooks module. Every use of `$` lives in this file; detection,
2// masking, policy, command analysis and messages are pure modules that never
3// receive it.
4//
5// Rules this file keeps:
6// - Every `$` call is spelled in full, and event names are string literals.
7// - A hook that can deny has a `.catch` that denies (monitor mode: lets it go).
8// - Nothing that holds a secret is stored, logged or shown: findings are turned
9//   into MaskedFindings first, and only those cross into `$`.
10// - It never approves a call on its own: `next(e)` after a pass leaves the
11//   user's permissions and rules in force.
12
13import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
14
15import type { Decision, StoredConfig, StoredFinding } from '../types'
16import { analyzeCommand } from './commands.ts'
17import { EMPTY_CONFIG, matchesAny, parseConfig, toRule } from './config.ts'
18import type { CommandFacts, GitOp } from './commands.ts'
19import { classifyPath, scanEdit, scanText, scanWrite } from './detect.ts'
20import type { Finding, Rule, ScanResult } from './detect.ts'
21import { scanDiff } from './diff.ts'
22import type { DiffFinding } from './diff.ts'
23import { describe, fingerprint, mask, redact } from './mask.ts'
24import type { MaskedFinding } from './mask.ts'
25import * as say from './messages.ts'
26import type { WriteTool } from './messages.ts'
27import { MAX_READ, collect, isLikelyBinary, toolLabel } from './outbound.ts'
28import { decide, decideAll } from './policy.ts'
29import type { Action, Destination, Mode } from './policy.ts'
30import { USAGE, allowedText, bannerLine, fit, historyRows, mergeAllowed, parseArgs, resolveIds, summaryText } from './ui.ts'
31
32const { USE_ENV, ALLOW_ONCE, CANCEL, SHOW_NAMES, ADD_GITIGNORE } = say
33
34const MAX_FINDINGS = 100
35const MAX_ALLOW_ONCE = 500
36const MAX_UNTRACKED_READ = 200
37const GIT_TIMEOUT_MS = 20000
38
39const findingsRef = { plugin: 'leakstop', key: 'findings' } as const
40const allowOnceRef = { plugin: 'leakstop', key: 'allowOnce' } as const
41const pausedRef = { plugin: 'leakstop', key: 'paused' } as const
42const bannerRef = { plugin: 'leakstop', key: 'banner' } as const
43const configRef = { plugin: 'leakstop', key: 'config' } as const
44
45/** The senders of a prompt that are not the user at the keyboard. */
46const AUTOMATED: ReadonlySet<string> = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel', 'coordinator', 'observer', 'observer-activity', 'slack-ping', 'plugin'])
47
48const PANE = 'leakstop'
49const MAX_BANNER = 5
50const MAX_ALLOWED_FOREVER = 1000
51
52/** What is kept about a finding or a sensitive operation: never a value. */
53type Note = Pick<MaskedFinding, 'fingerprint' | 'ruleId' | 'label' | 'severity' | 'line'> & { path?: string }
54
55/** The outcome of a check: nothing to say, a denial, or a rewritten command. */
56type Verdict = { deny: string } | { command: string } | undefined
57
58// --- Helpers that take `$` (top-level functions of this file) ---------------
59
60/** True when git ignores `path`. Anything unexpected (not a repo, no git) reads as "not ignored". */
61async function isIgnored($: EngineInterface, path: string): Promise<boolean> {
62  try {
63    const result = await $.process.run(['git', 'check-ignore', '-q', '--', path], { timeoutMs: 5000 })
64    return result.exitCode === 0
65  } catch {
66    return false
67  }
68}
69
70async function sessionCwd($: EngineInterface): Promise<string | undefined> {
71  try {
72    return await $.session.cwd()
73  } catch {
74    return undefined
75  }
76}
77
78/** Line of `oldString` in the file on disk, so an Edit's findings point at real lines. 0 when unknown. */
79async function lineOffset($: EngineInterface, path: string, oldString: string): Promise<number> {
80  if (oldString === '') return 0
81  try {
82    const content = await $.fs.read(path)
83    if (typeof content !== 'string') return 0
84    const index = content.indexOf(oldString)
85    if (index < 0) return 0
86    let lines = 0
87    for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) lines++
88    return lines
89  } catch {
90    return 0
91  }
92}
93
94async function record($: EngineInterface, tool: string, path: string, notes: readonly Note[], decision: Decision): Promise<void> {
95  const at = await $.clock.now()
96  const entries: StoredFinding[] = notes.map((n) => ({
97    fingerprint: n.fingerprint,
98    ruleId: n.ruleId,
99    label: n.label,
100    severity: n.severity,
101    path: n.path ?? path,
102    line: n.line,
103    tool,
104    decision,
105    at,
106  }))
107  const { value = [] } = await $.state.get(findingsRef)
108  await $.state.set(findingsRef, [...value, ...entries].slice(-MAX_FINDINGS))
109  if (decision === 'warned' || decision === 'masked') {
110    const { value: banner = [] } = await $.state.get(bannerRef)
111    await $.state.set(bannerRef, [...banner, ...entries].slice(-MAX_BANNER))
112  }
113}
114
115/** True where a banner can be drawn: the terminal and the desktop app. The VS Code panel and `claude -p` draw nothing. */
116async function drawsBanner($: EngineInterface): Promise<boolean> {
117  try {
118    const surfaces = await $.session.surfaces()
119    return surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')
120  } catch {
121    return false
122  }
123}
124
125async function clearBanner($: EngineInterface): Promise<void> {
126  const { value = [] } = await $.state.get(bannerRef)
127  if (value.length > 0) await $.state.set(bannerRef, [])
128}
129
130async function setPaused($: EngineInterface, paused: boolean, hasStatus: boolean): Promise<void> {
131  await $.state.set(pausedRef, paused)
132  if (hasStatus) showStatus($, paused)
133}
134
135/** The line under the prompt that says LeakStop is on: when it is missing, nothing is protecting the session. */
136function showStatus($: EngineInterface, paused: boolean): void {
137  $.ui.status(paused ? '△ LeakStop paused · /leakstop resume' : '◆ LeakStop on')
138}
139
140/** Allows findings for good: the store is shared by every session on the machine. */
141async function allowForever($: EngineInterface, fingerprints: readonly string[]): Promise<void> {
142  const stored = await $.store.get('allowFingerprints')
143  const current = Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
144  await $.store.set('allowFingerprints', [...new Set([...current, ...fingerprints])].slice(-MAX_ALLOWED_FOREVER))
145}
146
147async function rememberAllowOnce($: EngineInterface, fingerprints: readonly string[]): Promise<void> {
148  const { value = [] } = await $.state.get(allowOnceRef)
149  await $.state.set(allowOnceRef, [...new Set([...value, ...fingerprints])].slice(-MAX_ALLOW_ONCE))
150}
151
152/** Reads the project's `.leakstop.json` (at session start and on `/leakstop reload`). A missing file is not a problem; a bad one is reported and the defaults apply. */
153async function loadConfig($: EngineInterface): Promise<StoredConfig> {
154  let config: StoredConfig = EMPTY_CONFIG
155  let exists = false
156  try {
157    exists = await $.fs.exists('.leakstop.json')
158  } catch {
159    exists = false
160  }
161  if (exists) {
162    try {
163      const content = await $.fs.read('.leakstop.json')
164      config = typeof content === 'string' ? parseConfig(content) : { ...EMPTY_CONFIG, warnings: ['.leakstop.json could not be read as text, so the defaults apply'] }
165    } catch {
166      config = { ...EMPTY_CONFIG, warnings: ['.leakstop.json could not be read (is it over 4 MiB?), so the defaults apply'] }
167    }
168  }
169  await $.state.set(configRef, config)
170  return config
171}
172
173async function getConfig($: EngineInterface): Promise<StoredConfig> {
174  const { value } = await $.state.get(configRef)
175  return value ?? EMPTY_CONFIG
176}
177
178/** The project's custom rules, compiled. */
179const customRules = (config: StoredConfig): Rule[] => config.customRules.map(toRule).filter((rule): rule is Rule => rule !== undefined)
180
181/** Medium findings in a path the project told us to ignore are dropped; critical ones never are. */
182const isRelaxed = (config: StoredConfig, severity: string, path: string): boolean => severity === 'medium' && matchesAny(path, config.ignorePaths)
183
184/** What is allowed, by where it came from: this session, the user for good (the machine-wide store) and the project. */
185async function allowedSources($: EngineInterface, config: StoredConfig): Promise<{ session: string[]; forever: string[]; project: string[] }> {
186  const { value: session = [] } = await $.state.get(allowOnceRef)
187  let stored: unknown
188  try {
189    stored = await $.store.get('allowFingerprints')
190  } catch {
191    stored = undefined
192  }
193  const forever = Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
194  return { session, forever, project: config.allowFingerprints }
195}
196
197/** Fingerprints allowed for this session, the ones the user allowed for good and the ones the project allows. */
198async function allowedFingerprints($: EngineInterface, config: StoredConfig): Promise<Set<string>> {
199  const { session, forever, project } = await allowedSources($, config)
200  return new Set([...session, ...forever, ...project])
201}
202
203/** Stops allowing `fingerprints` (every one of the user's when `undefined`); the project's own list is not touched. Returns what was removed. */
204async function forgetAllowed($: EngineInterface, fingerprints: readonly string[] | undefined): Promise<{ session: string[]; forever: string[] }> {
205  const { session, forever } = await allowedSources($, EMPTY_CONFIG)
206  const drop = (list: readonly string[]): string[] => (fingerprints === undefined ? [...list] : list.filter((f) => fingerprints.includes(f)))
207  const removed = { session: drop(session), forever: drop(forever) }
208  if (removed.session.length > 0) await $.state.set(allowOnceRef, session.filter((f) => !removed.session.includes(f)))
209  if (removed.forever.length > 0) {
210    const kept = forever.filter((f) => !removed.forever.includes(f))
211    if (kept.length > 0) await $.store.set('allowFingerprints', kept)
212    else await $.store.delete('allowFingerprints')
213  }
214  return removed
215}
216
217/** True when VS Code is the only place the session draws: its dialog runs the lines of a question together. */
218async function isVsCodeOnly($: EngineInterface): Promise<boolean> {
219  try {
220    const surfaces = await $.session.surfaces()
221    return surfaces.includes('vscode') && !surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')
222  } catch {
223    return false
224  }
225}
226
227/** The user's answer, or `undefined` when nobody could answer (dismissed, `claude -p`, no interface). */
228async function askUser($: EngineInterface, question: string, options: readonly string[]): Promise<string | undefined> {
229  try {
230    return await $.ui.ask((await isVsCodeOnly($)) ? say.flatten(question) : question, { options, header: 'LeakStop' })
231  } catch {
232    return undefined
233  }
234}
235
236type Spec = {
237  action: Action
238  tool: string
239  path: string
240  notes: readonly Note[]
241  question: string
242  options: readonly string[]
243  deny: string
244  /** Transcript lines for a warning. */
245  warn: readonly string[]
246  /** The command to run instead when the user picks "Show names only". */
247  rewrite?: string
248}
249
250/** Carries out the policy's action: pass, warn, hold (ask) or block. `undefined` means the call may go on. */
251async function settle($: EngineInterface, spec: Spec): Promise<Verdict> {
252  switch (spec.action) {
253    case 'pass':
254      await record($, spec.tool, spec.path, spec.notes, 'passed')
255      return undefined
256    case 'warn':
257      // Where a banner is drawn it says it; where nothing is drawn, the transcript does.
258      if (!(await drawsBanner($))) for (const line of spec.warn) $.ui.log(line)
259      await record($, spec.tool, spec.path, spec.notes, 'warned')
260      return undefined
261    case 'hold': {
262      const answer = await askUser($, spec.question, spec.options)
263      if (answer === ALLOW_ONCE) {
264        await rememberAllowOnce($, spec.notes.map((n) => n.fingerprint))
265        await record($, spec.tool, spec.path, spec.notes, 'allowed')
266        return undefined
267      }
268      if (answer === SHOW_NAMES && spec.rewrite !== undefined) {
269        await record($, spec.tool, spec.path, spec.notes, 'allowed')
270        return { command: spec.rewrite }
271      }
272      await record($, spec.tool, spec.path, spec.notes, 'denied')
273      return { deny: `${say.answerNote(answer)} ${spec.deny}` }
274    }
275    case 'block':
276      await record($, spec.tool, spec.path, spec.notes, 'denied')
277      return { deny: spec.deny }
278  }
279}
280
281/** A sensitive operation (no secret value to fingerprint) as a Note, identified by what it is about. */
282async function operation(ruleId: string, label: string, about: string): Promise<Note> {
283  return { fingerprint: await fingerprint(about), ruleId, label, severity: 'critical', line: 0 }
284}
285
286/** Editing LeakStop's own configuration is always held: otherwise Claude could allowlist its own findings. */
287async function checkConfig($: EngineInterface, tool: string, shownPath: string, mode: Mode): Promise<Verdict> {
288  if (decide('config-edit', 'critical', mode) !== 'hold') {
289    $.ui.log(say.noticeLine(`the change to ${shownPath} would have been held`, false))
290    return undefined
291  }
292  const answer = await askUser($, say.configQuestion(shownPath), [ALLOW_ONCE, CANCEL])
293  if (answer === ALLOW_ONCE) return undefined
294  await record($, tool, shownPath, [await operation('config-edit', 'LeakStop configuration change', `config:${shownPath}`)], 'denied')
295  return { deny: `${say.answerNote(answer)} ${say.configDenyMessage(shownPath)}` }
296}
297
298/** True when the file is sensitive by path and, for `.npmrc` and `.pypirc`, holds a token. */
299async function isSensitiveFile($: EngineInterface, path: string, dir?: string): Promise<boolean> {
300  const kind = classifyPath(path)
301  if (kind === undefined) return false
302  if (!kind.requiresToken) return true
303  try {
304    const content = await $.fs.read(dir === undefined || path.startsWith('/') ? path : `${dir}/${path}`)
305    return typeof content === 'string' && scanText(content, { path }).findings.length > 0
306  } catch {
307    return false
308  }
309}
310
311async function git($: EngineInterface, args: readonly string[], dir?: string): Promise<{ isOk: boolean; stdout: string; isTruncated: boolean }> {
312  const init = dir === undefined ? { timeoutMs: GIT_TIMEOUT_MS } : { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }
313  const result = await $.process.run(['git', ...args], init)
314  return { isOk: result.exitCode === 0, stdout: result.stdout, isTruncated: result.isStdoutTruncated }
315}
316
317const names = (output: string): string[] => output.split('\0').filter((name) => name !== '')
318
319const normalize = (path: string): string => path.replace(/^\.\//, '').replace(/\/+$/, '')
320
321const isInScope = (file: string, paths: readonly string[]): boolean => paths.some((p) => file === normalize(p) || file.startsWith(`${normalize(p)}/`))
322
323// --- Bash checks -------------------------------------------------------------
324
325/** A literal secret inside the command: a `curl` header, an `export`, a `--build-arg`, a heredoc. */
326async function checkSecrets($: EngineInterface, command: string, mode: Mode, allowed: ReadonlySet<string>, config: StoredConfig, writeTargets?: readonly string[]): Promise<Verdict> {
327  const scan = scanText(command, { extraRules: customRules(config) })
328  if (scan.isPartial) $.ui.log('LeakStop: the custom rules were too slow and did not cover the whole command')
329  const found = scan.findings
330  if (found.length === 0) return undefined
331  const masked = (await Promise.all(found.map(describe))).filter((f) => !allowed.has(f.fingerprint))
332  if (masked.length === 0) return undefined
333  // `cat > .env <<EOF … EOF` or `echo KEY=… >> .env` into files git ignores is where a secret belongs.
334  let destination: Destination = 'command'
335  if (writeTargets !== undefined) {
336    const ignored = await Promise.all(writeTargets.map((target) => isIgnored($, target)))
337    if (ignored.every(Boolean)) destination = 'ignored-file'
338  }
339  const action = decideAll(destination, masked.map((f) => f.severity), mode)
340  return settle($, {
341    action,
342    tool: 'Bash',
343    path: '',
344    notes: masked,
345    question: say.commandSecretQuestion(masked),
346    options: [USE_ENV, ALLOW_ONCE, CANCEL],
347    deny: say.commandSecretDeny(masked),
348    warn: masked.map((f) => say.noticeLine(`${f.severity.toUpperCase()} · ${f.label} in a command`, mode === 'monitor')),
349  })
350}
351
352/** Printing sensitive files, the whole environment or a secret variable into the conversation. */
353async function checkSensitive($: EngineInterface, facts: CommandFacts, mode: Mode, allowed: ReadonlySet<string>): Promise<Verdict> {
354  const action = decide('sensitive-dump', 'critical', mode)
355  let command: string | undefined
356
357  const files: string[] = []
358  for (const file of facts.readFiles) if (await isSensitiveFile($, file)) files.push(file)
359  const fileNotes = await Promise.all(files.map((file) => operation('sensitive-file-read', 'Sensitive file printed', `path:${file}`)))
360  const pendingFiles = files.filter((_, i) => !allowed.has((fileNotes[i] as Note).fingerprint))
361  if (pendingFiles.length > 0) {
362    const notes = fileNotes.filter((n) => !allowed.has(n.fingerprint))
363    const verdict = await settle($, {
364      action,
365      tool: 'Bash',
366      path: pendingFiles.join(', '),
367      notes,
368      question: say.dumpQuestion('This command would print files that hold secrets', pendingFiles),
369      options: facts.namesOnly === undefined ? [ALLOW_ONCE, CANCEL] : [SHOW_NAMES, ALLOW_ONCE, CANCEL],
370      deny: say.fileReadDeny(pendingFiles),
371      warn: [say.noticeLine(`${pendingFiles.join(', ')} would be printed`, mode === 'monitor')],
372      rewrite: facts.namesOnly,
373    })
374    if (verdict !== undefined && 'deny' in verdict) return verdict
375    if (verdict !== undefined) command = verdict.command
376  }
377
378  if (facts.isEnvDump) {
379    const note = await operation('environment-dump', 'Environment printed', 'env-dump')
380    if (!allowed.has(note.fingerprint)) {
381      const verdict = await settle($, {
382        action,
383        tool: 'Bash',
384        path: 'environment',
385        notes: [note],
386        question: say.dumpQuestion('This command would print the whole environment', ['printenv / env']),
387        options: facts.namesOnly === undefined ? [ALLOW_ONCE, CANCEL] : [SHOW_NAMES, ALLOW_ONCE, CANCEL],
388        deny: say.ENV_DUMP_DENY,
389        warn: [say.noticeLine('the whole environment would be printed', mode === 'monitor')],
390        rewrite: facts.namesOnly,
391      })
392      if (verdict !== undefined && 'deny' in verdict) return verdict
393      if (verdict !== undefined) command = verdict.command
394    }
395  }
396
397  if (facts.secretVars.length > 0) {
398    const notes = await Promise.all(facts.secretVars.map((name) => operation('secret-variable-print', 'Secret variable printed', `env-var:${name}`)))
399    const pending = facts.secretVars.filter((_, i) => !allowed.has((notes[i] as Note).fingerprint))
400    if (pending.length > 0) {
401      const verdict = await settle($, {
402        action,
403        tool: 'Bash',
404        path: pending.join(', '),
405        notes: notes.filter((n) => !allowed.has(n.fingerprint)),
406        question: say.dumpQuestion('This command would print secret variables', pending),
407        options: [ALLOW_ONCE, CANCEL],
408        deny: say.secretVarDeny(pending),
409        warn: [say.noticeLine(`${pending.join(', ')} would be printed`, mode === 'monitor')],
410      })
411      if (verdict !== undefined && 'deny' in verdict) return verdict
412    }
413  }
414
415  return command === undefined ? undefined : { command }
416}
417
418/** Sensitive files a recursive search could reach, found by looking in the folders it was given. */
419const FIND_SENSITIVE: readonly string[] = [
420  '(',
421  ...['.env', '.env.*', '*.env', '*.pem', '*.key', '*.p12', '*.pfx', 'id_rsa*', 'id_dsa*', 'id_ecdsa*', 'id_ed25519*', 'credentials.json', 'service-account*.json', '*.tfstate', '*.tfstate.backup'].flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])),
422  ')',
423  '-type',
424  'f',
425  '-not',
426  '-path',
427  '*/node_modules/*',
428  '-not',
429  '-path',
430  '*/.git/*',
431]
432
433/** `grep -r KEY .` reads `.env` too: grep ignores .gitignore and hidden-file rules. Hold when a sensitive file is within reach. */
434async function checkSearch($: EngineInterface, search: Search, mode: Mode, allowed: ReadonlySet<string>): Promise<Verdict> {
435  const found: string[] = []
436  for (const dir of search.dirs.slice(0, 5)) {
437    try {
438      const result = await $.process.run(['find', dir, '-maxdepth', '8', ...FIND_SENSITIVE], { timeoutMs: 5000 })
439      for (const file of names(result.stdout.replace(/\n/g, '\0'))) found.push(file.replace(/^\.\//, ''))
440    } catch {
441      // A folder that cannot be listed (or takes too long) is not a reason to block the search.
442    }
443  }
444  const candidates = [...new Set(found)]
445    .filter((file) => classifyPath(file) !== undefined && classifyPath(file)?.requiresToken === false)
446    .filter((file) => !matchesAny(file, search.excludes))
447    .filter((file) => search.includes.length === 0 || matchesAny(file, search.includes))
448    .slice(0, 20)
449  // A search that honours .gitignore never opens the files git ignores (a plain .env).
450  const reach: string[] = []
451  for (const file of candidates) if (!(search.respectsIgnore && (await isIgnored($, file)))) reach.push(file)
452  if (reach.length === 0) return undefined
453
454  const note = await operation('sensitive-search', 'Search through sensitive files', `search:${[...reach].sort().join('\n')}`)
455  if (allowed.has(note.fingerprint)) return undefined
456  return settle($, {
457    action: decide('sensitive-dump', 'critical', mode),
458    tool: 'Bash',
459    path: reach.join(', '),
460    notes: [note],
461    question: say.searchQuestion(reach),
462    options: [ALLOW_ONCE, CANCEL],
463    deny: say.searchDeny(reach),
464    warn: [say.noticeLine(`a search would print lines from ${reach.join(', ')}`, mode === 'monitor')],
465  })
466}
467
468/** `git add -A` or `.` (or a named path) that would stage sensitive files git does not ignore. */
469async function checkGitAdd($: EngineInterface, op: Extract<GitOp, { kind: 'add' }>, mode: Mode, allowed: ReadonlySet<string>): Promise<Verdict> {
470  const untracked = await git($, ['ls-files', '--others', '--exclude-standard', '-z'], op.dir)
471  const modified = await git($, ['diff', '--name-only', '-z'], op.dir)
472  if (!untracked.isOk && !modified.isOk) return undefined
473  const candidates = [...new Set([...names(untracked.stdout), ...names(modified.stdout)])].filter((file) => op.isAll || isInScope(file, op.paths))
474
475  const sensitive: string[] = []
476  for (const file of candidates.slice(0, 1000)) if (await isSensitiveFile($, file, op.dir)) sensitive.push(file)
477  if (sensitive.length === 0) return undefined
478
479  const note = await operation('git-add-sensitive', 'Sensitive files staged', `git-add:${[...sensitive].sort().join('\n')}`)
480  if (allowed.has(note.fingerprint)) return undefined
481  return settle($, {
482    action: decide('git-add', 'critical', mode),
483    tool: 'Bash',
484    path: sensitive.join(', '),
485    notes: [note],
486    question: say.gitAddQuestion(sensitive),
487    options: [ADD_GITIGNORE, ALLOW_ONCE, CANCEL],
488    deny: say.gitAddDeny(sensitive),
489    warn: [say.noticeLine(`git add would stage ${sensitive.join(', ')}`, mode === 'monitor')],
490  })
491}
492
493type Pending = { findings: DiffFinding[]; isTruncated: boolean }
494
495/** What a commit is about to contain: the staged diff and, when the command stages first, the rest. */
496async function pendingForCommit($: EngineInterface, op: Extract<GitOp, { kind: 'commit' }>, config: StoredConfig): Promise<Pending> {
497  const cached = await git($, ['diff', '--cached', '--no-color', '-U0'], op.dir)
498  if (!cached.isOk) return { findings: [], isTruncated: false }
499  const diffs = [cached.stdout]
500  let isTruncated = cached.isTruncated
501
502  const stagesAll = op.staging.some((s) => s.isAll)
503  const stagedPaths = op.staging.flatMap((s) => (s.isAll ? [] : s.paths))
504  // `-a`, or a `git add` earlier in the same command, stages tracked changes that are not staged yet.
505  if (op.isAll || stagesAll) {
506    const unstaged = await git($, ['diff', '--no-color', '-U0'], op.dir)
507    if (unstaged.isOk) diffs.push(unstaged.stdout)
508    isTruncated ||= unstaged.isTruncated
509  } else if (stagedPaths.length > 0) {
510    const unstaged = await git($, ['diff', '--no-color', '-U0', '--', ...stagedPaths], op.dir)
511    if (unstaged.isOk) diffs.push(unstaged.stdout)
512    isTruncated ||= unstaged.isTruncated
513  }
514
515  const extraRules = customRules(config)
516  const findings = scanDiff(diffs.join('\n'), extraRules).findings
517
518  // New files that an earlier `git add` in the same command would stage.
519  if (stagesAll || stagedPaths.length > 0) {
520    const untracked = await git($, ['ls-files', '--others', '--exclude-standard', '-z'], op.dir)
521    const files = names(untracked.stdout).filter((file) => stagesAll || isInScope(file, stagedPaths))
522    for (const file of files.slice(0, MAX_UNTRACKED_READ)) {
523      try {
524        const content = await $.fs.read(op.dir === undefined ? file : `${op.dir}/${file}`)
525        if (typeof content === 'string') for (const finding of scanText(content, { path: file, extraRules }).findings) findings.push({ ...finding, path: file })
526      } catch {
527        // Unreadable or over 4 MiB: skipped, as the spec says.
528      }
529    }
530  }
531  return { findings, isTruncated }
532}
533
534/** What a push is about to publish: the added lines of every commit no remote has. */
535async function pendingForPush($: EngineInterface, op: Extract<GitOp, { kind: 'push' }>, config: StoredConfig): Promise<Pending> {
536  const log = await git($, ['log', '-p', '--no-color', '--format=', 'HEAD', '--not', '--remotes'], op.dir)
537  if (!log.isOk) return { findings: [], isTruncated: false }
538  return { findings: scanDiff(log.stdout, customRules(config)).findings, isTruncated: log.isTruncated }
539}
540
541/** Secrets in what is about to be committed or pushed: blocked without asking. */
542async function checkPublish($: EngineInterface, kind: 'commit' | 'push', pending: Pending, mode: Mode, allowed: ReadonlySet<string>, config: StoredConfig): Promise<Verdict> {
543  if (pending.isTruncated) $.ui.log('LeakStop: the diff is larger than 4 MiB, so only the first 4 MiB was scanned')
544  if (pending.findings.length === 0) return undefined
545  const described = await Promise.all(pending.findings.map(async (f) => ({ ...(await describe(f)), path: f.path })))
546  const masked = described.filter((f) => !allowed.has(f.fingerprint) && !isRelaxed(config, f.severity, f.path))
547  if (masked.length === 0) return undefined
548  const destination: Destination = kind === 'commit' ? 'git-commit' : 'git-push'
549  return settle($, {
550    action: decideAll(destination, masked.map((f) => f.severity), mode),
551    tool: 'Bash',
552    path: '',
553    notes: masked,
554    question: '',
555    options: [],
556    deny: say.gitBlockMessage(kind, masked),
557    warn: masked.map((f) => say.noticeLine(`${f.severity.toUpperCase()} · ${f.label} in ${f.path}:${f.line} (git ${kind})`, mode === 'monitor')),
558  })
559}
560
561async function checkGit($: EngineInterface, op: GitOp, mode: Mode, allowed: ReadonlySet<string>, config: StoredConfig): Promise<Verdict> {
562  if (op.kind === 'add') return checkGitAdd($, op, mode, allowed)
563  if (op.kind === 'commit') return checkPublish($, 'commit', await pendingForCommit($, op, config), mode, allowed, config)
564  return checkPublish($, 'push', await pendingForPush($, op, config), mode, allowed, config)
565}
566
567// --- Outbound tools ------------------------------------------------------------
568
569/** Secrets in what a tool sends away: the web, another agent or session, a published page, an MCP server. */
570async function guardOutbound($: EngineInterface, e: ToolCallInput, mode: Mode): Promise<Verdict> {
571  const { value: paused = false } = await $.state.get(pausedRef)
572  if (paused) return undefined
573
574  const tool = e.tool
575  const label = toolLabel(tool)
576  const config = await getConfig($)
577  const allowed = await allowedFingerprints($, config)
578  const rules = customRules(config)
579  const out = collect(e as unknown as Record<string, unknown>)
580  if (out.isTruncated) $.ui.log(`LeakStop: ${label} carries more than LeakStop reads, so only part of it was scanned`)
581  const cwd = await sessionCwd($)
582
583  // An argument's name comes from the model and can itself be a secret: never show one that matches a rule.
584  const place = (field: string): string => (scanText(field, { extraRules: rules }).findings.length > 0 ? '[argument]' : field)
585
586  const found: (Finding & { path: string })[] = []
587  for (const part of out.texts) {
588    const where = place(part.field)
589    const scan = scanText(part.text, { extraRules: rules })
590    if (scan.isSkipped) $.ui.log(`LeakStop: ${label} ${where} is larger than 4 MiB and was not scanned`)
591    if (scan.isPartial) $.ui.log(`LeakStop: the custom rules were too slow and did not cover ${label} ${where}`)
592    // The place is the argument, not a line: it is not a file.
593    for (const finding of scan.findings) found.push({ ...finding, line: 0, path: `${label} › ${where}` })
594  }
595
596  // Files the call sends: sensitive ones are held as they are, the rest are read and scanned.
597  const sensitive: string[] = []
598  let opened = 0
599  for (const file of out.files) {
600    if (await isSensitiveFile($, file)) {
601      sensitive.push(file)
602      continue
603    }
604    if (isLikelyBinary(file)) continue
605    // Past the limit a file is still checked by its name, just not opened.
606    if (opened >= MAX_READ) {
607      if (opened++ === MAX_READ) $.ui.log(`LeakStop: ${label} sends more than ${MAX_READ} files, so only the first ${MAX_READ} were read`)
608      continue
609    }
610    opened++
611    try {
612      const content = await $.fs.read(file)
613      if (typeof content !== 'string') continue
614      const shown = displayPathOf(file, cwd)
615      const scan = scanText(content, { path: file, extraRules: rules })
616      if (scan.isSkipped) $.ui.log(`LeakStop: ${shown} is larger than 4 MiB and was not scanned`)
617      for (const finding of scan.findings) found.push({ ...finding, path: shown })
618    } catch {
619      // Unreadable or over 4 MiB: it cannot be checked, and the tool will say if it cannot read it either.
620    }
621  }
622
623  const fileNotes = await Promise.all(sensitive.map((file) => operation('sensitive-file-send', 'Sensitive file sent', `path:${file}`)))
624  const pendingFiles = sensitive.filter((_, i) => !allowed.has((fileNotes[i] as Note).fingerprint)).map((file) => displayPathOf(file, cwd))
625  if (pendingFiles.length > 0) {
626    const verdict = await settle($, {
627      action: decide('sensitive-dump', 'critical', mode),
628      tool,
629      path: pendingFiles.join(', '),
630      notes: fileNotes.filter((n) => !allowed.has(n.fingerprint)),
631      question: say.outboundFileQuestion(tool, pendingFiles),
632      options: [ALLOW_ONCE, CANCEL],
633      deny: say.outboundFileDeny(tool, pendingFiles),
634      warn: [say.noticeLine(`${label} would send ${pendingFiles.join(', ')}`, mode === 'monitor')],
635    })
636    if (verdict !== undefined && 'deny' in verdict) return verdict
637  }
638
639  if (found.length === 0) return undefined
640  const described = await Promise.all(found.map(async (f) => ({ ...(await describe(f)), path: f.path })))
641  const masked = described.filter((f) => !allowed.has(f.fingerprint) && !isRelaxed(config, f.severity, f.path))
642  if (masked.length === 0) return undefined
643  return settle($, {
644    action: decideAll('outbound', masked.map((f) => f.severity), mode),
645    tool,
646    path: '',
647    notes: masked,
648    question: say.outboundQuestion(tool, masked),
649    options: [ALLOW_ONCE, CANCEL],
650    deny: say.outboundDeny(tool, masked),
651    warn: masked.map((f) => say.noticeLine(`${f.severity.toUpperCase()} · ${f.label} in ${f.path}`, mode === 'monitor')),
652  })
653}
654
655// --- Pure helpers of this file (no `$`) -------------------------------------
656
657type Call = { tool: WriteTool; path: string; result: ScanResult; oldString?: string }
658
659/** What the write is about to put on disk, scanned. `undefined`: nothing is written. */
660function scanCall(e: ToolCallInput, extraRules: readonly Rule[]): Call | undefined {
661  switch (e.tool) {
662    case 'Write':
663      return { tool: 'Write', path: e.file_path, result: scanWrite(e.file_path, e.content, { extraRules }) }
664    case 'Edit':
665      return { tool: 'Edit', path: e.file_path, result: scanEdit(e.file_path, e.old_string, e.new_string, { extraRules }), oldString: e.old_string }
666    case 'NotebookEdit':
667      return typeof e.new_source === 'string'
668        ? { tool: 'NotebookEdit', path: e.notebook_path, result: scanText(e.new_source, { path: e.notebook_path, extraRules }) }
669        : undefined
670    default:
671      return undefined
672  }
673}
674
675const isConfigPath = (path: string): boolean => (path.split(/[\\/]/).pop() ?? '') === '.leakstop.json'
676
677type Failure = { called: boolean; error: { kind: string } }
678
679/** Fail closed: a hook that throws or runs out of time is skipped and the call would go on. Monitor mode never blocks. */
680function onFailure(mode: Mode, next: Failure): { deny: string } | undefined {
681  if (mode === 'monitor' || next.called) return undefined
682  return { deny: `LeakStop could not check this call (${next.error.kind}), so it was blocked. Try again, or ask the user to review it.` }
683}
684
685// --- Tool output ---------------------------------------------------------------
686
687/** Severities masked out of what a tool returns; monitor mode only reports them. */
688const outputSeverities = (mode: Mode): ReadonlySet<string> => new Set(mode === 'strict' ? ['critical', 'medium'] : ['critical'])
689
690/** Keys of an MCP result that carry binary data (images, audio, blobs), not text. */
691const BINARY_KEYS: ReadonlySet<string> = new Set(['data', 'blob'])
692
693type OutputScan = {
694  /** Masks the secrets in one piece of output; in monitor mode the text comes back as it was. */
695  take: (text: string) => Promise<string>
696  notes: Map<string, MaskedFinding>
697  skipped: number
698}
699
700function outputScan(mode: Mode, allowed: ReadonlySet<string>, rules: readonly Rule[]): OutputScan {
701  const severities = outputSeverities(mode)
702  const scan: OutputScan = {
703    notes: new Map(),
704    skipped: 0,
705    take: async (text) => {
706      if (text === '') return text
707      const result = scanText(text, { extraRules: rules })
708      if (result.isSkipped) scan.skipped++
709      const hits: Finding[] = []
710      for (const finding of result.findings) {
711        if (!severities.has(finding.severity)) continue
712        const note = await describe(finding)
713        if (allowed.has(note.fingerprint)) continue
714        hits.push(finding)
715        scan.notes.set(note.fingerprint, note)
716      }
717      return mode === 'monitor' || hits.length === 0 ? text : redact(text, hits)
718    },
719  }
720  return scan
721}
722
723/** The secrets on disk at `path` whose masked form `content` would write in their place. */
724async function maskedOverwrite($: EngineInterface, path: string, content: string): Promise<MaskedFinding[]> {
725  let current: unknown
726  try {
727    current = await $.fs.read(path)
728  } catch {
729    return []
730  }
731  if (typeof current !== 'string') return []
732  const lost: MaskedFinding[] = []
733  for (const finding of scanText(current).findings) {
734    if (content.includes(mask(finding.value, finding.prefix)) && !content.includes(finding.value)) lost.push(await describe(finding))
735  }
736  return lost
737}
738
739/**
740 * True for the file Claude Code saves a large output to: a regular file directly inside a
741 * `tool-results` folder. Any other path a result names is never rewritten.
742 */
743async function isSavedOutput($: EngineInterface, path: string): Promise<boolean> {
744  if (!/[\\/]tool-results[\\/][^\\/]+$/.test(path)) return false
745  const stat = await $.fs.stat(path)
746  return stat.kind === 'file' && stat.isLink !== true
747}
748
749/** Every string inside an MCP result, masked; binary fields are left alone. */
750async function maskDeep(value: unknown, take: (text: string) => Promise<string>, depth = 0): Promise<unknown> {
751  if (typeof value === 'string') return take(value)
752  if (depth > 20 || value === null || typeof value !== 'object') return value
753  if (Array.isArray(value)) {
754    const out: unknown[] = []
755    for (const item of value) out.push(await maskDeep(item, take, depth + 1))
756    return out
757  }
758  const out: Record<string, unknown> = {}
759  for (const [key, item] of Object.entries(value)) out[key] = BINARY_KEYS.has(key) ? item : await maskDeep(item, take, depth + 1)
760  return out
761}
762
763/**
764 * The tool's result with secrets masked, before the model or the transcript gets it. A large
765 * Bash output is also saved whole to a file before any hook runs: that file is masked too.
766 */
767async function maskOutput($: EngineInterface, e: ToolCallInput, r: any, mode: Mode): Promise<any> {
768  if (r === undefined || r.deny !== undefined || r.isError === true || r.result === null || typeof r.result !== 'object') return r
769  const { value: paused = false } = await $.state.get(pausedRef)
770  if (paused) return r
771  const config = await getConfig($)
772  const scan = outputScan(mode, await allowedFingerprints($, config), customRules(config))
773  let result = r.result
774  let path = ''
775
776  if (e.tool === 'Bash') {
777    result = { ...result, stdout: await scan.take(String(result.stdout ?? '')), stderr: await scan.take(String(result.stderr ?? '')) }
778    if (typeof result.persistedOutputPath === 'string') {
779      try {
780        if (!(await isSavedOutput($, result.persistedOutputPath))) throw new Error('not a saved output')
781        const saved = await $.fs.read(result.persistedOutputPath)
782        if (typeof saved === 'string') {
783          const masked = await scan.take(saved)
784          if (masked !== saved) await $.fs.write(result.persistedOutputPath, masked)
785        }
786      } catch {
787        $.ui.log('LeakStop: the saved output of this command could not be checked')
788      }
789    }
790  } else if (e.tool === 'Read') {
791    if (result.type !== 'text' || typeof result.file?.content !== 'string') return r
792    path = displayPathOf(e.file_path, await sessionCwd($))
793    result = { ...result, file: { ...result.file, content: await scan.take(result.file.content) } }
794  } else {
795    result = await maskDeep(result, scan.take)
796  }
797
798  if (scan.skipped > 0) $.ui.log(`LeakStop: an output of ${toolLabel(e.tool)} was larger than 4 MiB and was not checked`)
799  const notes = [...scan.notes.values()]
800  if (notes.length === 0) return r
801  await record($, e.tool, path, notes, mode === 'monitor' ? 'warned' : 'masked')
802  if (mode === 'monitor') return r
803  return { result, context: [...(r.context ?? []), say.maskedContext(toolLabel(e.tool), notes)] }
804}
805
806// --- The module -------------------------------------------------------------
807
808export const register: Register = (on, options) => {
809  const mode: Mode = options.mode === 'monitor' || options.mode === 'strict' ? options.mode : 'standard'
810  // Off by default: the line takes a row under the prompt in every session.
811  const hasStatus = options.statusLine === true
812
813  on('session.start', async ($, e, next) => {
814    await $.command.register({ name: 'leakstop', description: 'Show LeakStop findings, pause or resume protection, or allow a finding' })
815    const config = await loadConfig($)
816    for (const warning of config.warnings.slice(0, 5)) $.ui.log(`LeakStop: .leakstop.json: ${warning}`)
817    if (hasStatus) {
818      const { value: paused = false } = await $.state.get(pausedRef)
819      showStatus($, paused)
820    }
821    return next(e)
822  })
823
824  // A warning stays above the prompt until the user's next message. A background agent finishing, a peer
825  // session, a schedule or another plugin also submit prompts: they are not the user reading the warning.
826  // The desktop app and VS Code may stamp the user's own message as `sdk` or `unclassified`, so what clears it
827  // is anything that is not one of those automated senders.
828  on('prompt.submit', async ($, e, next) => {
829    if (!AUTOMATED.has(e.origin?.kind ?? '')) await clearBanner($)
830    // A secret pasted into the message is masked before the model or the transcript gets it.
831    // The prompt history (the up arrow) is the engine's and keeps what was typed.
832    try {
833      const { value: paused = false } = await $.state.get(pausedRef)
834      if (paused) return next(e)
835      const config = await getConfig($)
836      const scan = outputScan(mode, await allowedFingerprints($, config), customRules(config))
837      const text = await scan.take(e.text)
838      const notes = [...scan.notes.values()]
839      if (notes.length === 0) return next(e)
840      await record($, 'prompt', 'your message', notes, mode === 'monitor' ? 'warned' : 'masked')
841      if (mode === 'monitor') return next(e)
842      return next({ ...e, text, context: [...(e.context ?? []), say.promptMaskedContext(notes)] })
843    } catch {
844      return next(e) // a prompt is never blocked
845    }
846  })
847
848  on('command.run', { command: 'leakstop' }, async ($, e) => {
849    const args = parseArgs(e.args)
850    const { value: findings = [] } = await $.state.get(findingsRef)
851    const { value: paused = false } = await $.state.get(pausedRef)
852    const config = await getConfig($)
853
854    if (args.kind === 'open') {
855      await clearBanner($)
856      let isPlaced = false
857      try {
858        isPlaced = (await $.ui.open({ id: PANE, title: 'LeakStop', focus: true })).isPlaced
859      } catch {
860        isPlaced = false
861      }
862      // Where nothing is drawn (the VS Code panel) a "placed" panel is invisible: say it in text.
863      const isShown = isPlaced && (await drawsBanner($))
864      return isShown ? {} : { text: summaryText(findings, paused, config.warnings) }
865    }
866    if (args.kind === 'usage') return { text: USAGE }
867    if (args.kind === 'allowed') {
868      const { session, forever, project } = await allowedSources($, config)
869      return { text: allowedText(mergeAllowed(session, forever, project), findings) }
870    }
871
872    // Changing what LeakStop checks is the user's call. A command that did not
873    // come from the person (a task, a peer session, another plugin) is refused.
874    if (e.origin?.kind !== 'composer' && e.origin?.kind !== 'bridge') {
875      return { text: `LeakStop: /leakstop ${args.kind} only works when you type it yourself.` }
876    }
877    if (args.kind === 'pause') {
878      await setPaused($, true, hasStatus)
879      return { text: 'LeakStop paused: nothing is checked until you run /leakstop resume. Changes to .leakstop.json are still held.' }
880    }
881    if (args.kind === 'resume') {
882      await setPaused($, false, hasStatus)
883      return { text: 'LeakStop resumed.' }
884    }
885    if (args.kind === 'reload') {
886      const reloaded = await loadConfig($)
887      const parts = [`${reloaded.customRules.length} custom rule${reloaded.customRules.length === 1 ? '' : 's'}`, `${reloaded.ignorePaths.length} ignored path${reloaded.ignorePaths.length === 1 ? '' : 's'}`, `${reloaded.allowFingerprints.length} allowed fingerprint${reloaded.allowFingerprints.length === 1 ? '' : 's'}`]
888      const notes = reloaded.warnings.slice(0, 5).map((warning) => `.leakstop.json: ${warning}`)
889      return { text: [`LeakStop reloaded .leakstop.json: ${parts.join(', ')}.`, ...notes].join('\n') }
890    }
891    if (args.kind === 'forget') {
892      const isAll = args.ids.length === 1 && args.ids[0]?.toLowerCase() === 'all'
893      const { fingerprints, unknown } = isAll ? { fingerprints: undefined, unknown: [] as string[] } : resolveIds(args.ids, findings)
894      const removed = await forgetAllowed($, fingerprints)
895      const count = new Set([...removed.session, ...removed.forever]).size
896      const kept = (fingerprints ?? config.allowFingerprints).filter((f) => config.allowFingerprints.includes(f))
897      const done = count > 0 ? `Stopped allowing ${count} finding${count === 1 ? '' : 's'}: ${[...new Set([...removed.session, ...removed.forever])].join(' ')}.` : 'Nothing of yours was allowed, so nothing changed.'
898      const notes = [
899        ...(kept.length > 0 ? [`Still allowed by .leakstop.json (edit that file to remove): ${kept.join(' ')}.`] : []),
900        ...(unknown.length > 0 ? [`Not recognised: ${unknown.join(', ')}.`] : []),
901      ]
902      return { text: [done, ...notes, ...(unknown.length > 0 ? [USAGE] : [])].join('\n') }
903    }
904    const { fingerprints, unknown } = resolveIds(args.ids, findings)
905    if (fingerprints.length > 0) await allowForever($, fingerprints)
906    const done = fingerprints.length > 0 ? `Allowed for good: ${fingerprints.join(' ')}.` : 'Nothing was allowed.'
907    return { text: unknown.length > 0 ? `${done} Not recognised: ${unknown.join(', ')}.\n${USAGE}` : done }
908  })
909
910  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
911    if (e.props.hasSurvey) return next(e)
912    const { value: banner = [] } = await $.state.get(bannerRef)
913    const { value: paused = false } = await $.state.get(pausedRef)
914    if (!paused && banner.length === 0) return next(e)
915
916    const { Box, Text } = $.ui.resolve(e)
917    return (
918      <Box>
919        <Text color={paused ? 'red' : 'yellow'}>{bannerLine(banner, paused, e.props.bodyColumns)}</Text>
920      </Box>
921    )
922  })
923
924  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
925    const { Box, Button, Text } = $.ui.resolve(e)
926    const { value: findings = [] } = await $.state.get(findingsRef)
927    const { value: paused = false } = await $.state.get(pausedRef)
928    const width = e.props.bodyColumns
929    const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 4) / 2))
930    const rows = historyRows(findings, width).slice(0, room)
931
932    return (
933      <Box flexDirection="column">
934        {paused && <Text color="red">{fit('PAUSED · nothing is being checked', width)}</Text>}
935        {rows.length === 0 && <Text dimColor>No findings this session.</Text>}
936        {rows.map((row) => (
937          <Box flexDirection="column">
938            <Text>{row.head}</Text>
939            <Text dimColor>{row.detail}</Text>
940          </Box>
941        ))}
942        <Box gap={2}>
943          <Button key="pause" hotkey="1" label={paused ? 'Resume' : 'Pause'} onPress={() => setPaused($, !paused, hasStatus)} />
944          <Button key="close" hotkey="2" label="Close" onPress={() => $.ui.close({ id: PANE })} />
945        </Box>
946      </Box>
947    )
948  })
949
950  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
951    // Claude saw the masked form of a secret this file holds: writing the file back from what it saw would replace the real value.
952    if (e.tool === 'Write' && e.content.includes('••••••')) {
953      const lost = await maskedOverwrite($, e.file_path, e.content)
954      if (lost.length > 0) return { deny: say.maskedOverwriteDeny(displayPathOf(e.file_path, await sessionCwd($)), lost) }
955    }
956    const config = await getConfig($)
957    const call = scanCall(e, customRules(config))
958    if (call === undefined) return next(e)
959    const cwd = await sessionCwd($)
960    const shownPath = displayPathOf(call.path, cwd)
961
962    // Self-protection comes first and holds even when LeakStop is paused.
963    if (isConfigPath(call.path)) {
964      const verdict = await checkConfig($, call.tool, shownPath, mode)
965      return verdict === undefined ? next(e) : { deny: (verdict as { deny: string }).deny }
966    }
967
968    const { value: paused = false } = await $.state.get(pausedRef)
969    if (paused) return next(e)
970
971    if (call.result.isSkipped) {
972      $.ui.log(`LeakStop: ${shownPath} is larger than 4 MiB and was not scanned`)
973      return next(e)
974    }
975    if (call.result.isPartial) $.ui.log(`LeakStop: the custom rules were too slow and did not cover all of ${shownPath}`)
976    if (call.result.findings.length === 0) return next(e)
977
978    const offset = call.oldString === undefined ? 0 : await lineOffset($, call.path, call.oldString)
979    const described = await Promise.all(call.result.findings.map(describe))
980    const masked = described.map((f) => ({ ...f, line: f.line + offset })).filter((f) => !isRelaxed(config, f.severity, shownPath))
981    if (masked.length === 0) return next(e)
982
983    const allowed = await allowedFingerprints($, config)
984    const pending = masked.filter((f) => !allowed.has(f.fingerprint))
985    if (pending.length === 0) {
986      await record($, call.tool, shownPath, masked, 'allowed')
987      return next(e)
988    }
989
990    const destination: Destination = (await isIgnored($, call.path)) ? 'ignored-file' : 'file'
991    const severities = pending.map((f) => f.severity)
992    const wouldHold = mode === 'monitor' && decideAll(destination, severities, 'standard') === 'hold'
993    const verdict = await settle($, {
994      action: decideAll(destination, severities, mode),
995      tool: call.tool,
996      path: shownPath,
997      notes: pending,
998      question: say.holdQuestion(call.tool, shownPath, pending),
999      options: [USE_ENV, ALLOW_ONCE, CANCEL],
1000      deny: say.denyMessage(call.tool, shownPath, pending),
1001      warn: pending.map((f) => say.warnLine(shownPath, f, wouldHold)),
1002    })
1003    return verdict === undefined ? next(e) : { deny: (verdict as { deny: string }).deny }
1004  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1005
1006  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1007    const facts = analyzeCommand(e.command)
1008
1009    if (facts.touchesConfig) {
1010      const verdict = await checkConfig($, 'Bash', '.leakstop.json', mode)
1011      if (verdict !== undefined) return { deny: (verdict as { deny: string }).deny }
1012    }
1013
1014    const { value: paused = false } = await $.state.get(pausedRef)
1015    if (paused) return next(e)
1016
1017    const config = await getConfig($)
1018    const allowed = await allowedFingerprints($, config)
1019    let command = e.command
1020
1021    const secrets = await checkSecrets($, e.command, mode, allowed, config, facts.writeTargets)
1022    if (secrets !== undefined && 'deny' in secrets) return secrets
1023
1024    const sensitive = await checkSensitive($, facts, mode, allowed)
1025    if (sensitive !== undefined) {
1026      if ('deny' in sensitive) return sensitive
1027      command = sensitive.command
1028    }
1029
1030    for (const search of facts.searches) {
1031      const verdict = await checkSearch($, search, mode, allowed)
1032      if (verdict !== undefined && 'deny' in verdict) return verdict
1033    }
1034
1035    for (const op of facts.git) {
1036      const verdict = await checkGit($, op, mode, allowed, config)
1037      if (verdict !== undefined && 'deny' in verdict) return verdict
1038    }
1039
1040    return command === e.command ? next(e) : next({ ...e, command })
1041  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1042
1043  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1044    if (!(await isSensitiveFile($, e.file_path))) return next(e)
1045
1046    const { value: paused = false } = await $.state.get(pausedRef)
1047    if (paused) return next(e)
1048
1049    const note = await operation('sensitive-file-read', 'Sensitive file read', `path:${e.file_path}`)
1050    const allowed = await allowedFingerprints($, await getConfig($))
1051    if (allowed.has(note.fingerprint)) return next(e)
1052
1053    const cwd = await sessionCwd($)
1054    const shownPath = displayPathOf(e.file_path, cwd)
1055    const verdict = await settle($, {
1056      action: decide('read', 'critical', mode),
1057      tool: 'Read',
1058      path: shownPath,
1059      notes: [note],
1060      question: say.readQuestion(shownPath),
1061      options: [ALLOW_ONCE, CANCEL],
1062      deny: say.readDeny(shownPath, mode === 'strict'),
1063      warn: [say.noticeLine(`${shownPath} is a sensitive file`, mode === 'monitor')],
1064    })
1065    return verdict === undefined ? next(e) : { deny: (verdict as { deny: string }).deny }
1066  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1067
1068  // What leaves the session through a tool: a secret there cannot be taken back.
1069  on('tool.call', { tool: ['WebFetch', 'WebSearch', 'Agent', 'SendMessage', 'SendFile', 'Artifact', 'ArtifactData', 'ArtifactComments', 'PushNotification', 'SendFeedback', 'RemoteTrigger'] }, async ($, e, next) => {
1070    const verdict = await guardOutbound($, e, mode)
1071    return verdict === undefined ? next(e) : { deny: (verdict as { deny: string }).deny }
1072  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1073
1074  on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
1075    // An MCP tool that names .leakstop.json may rewrite it: ask, as for Write, Edit and the shell, even when paused.
1076    if (JSON.stringify(e).includes('.leakstop.json')) {
1077      const guard = await checkConfig($, e.tool, '.leakstop.json', mode)
1078      if (guard !== undefined) return { deny: (guard as { deny: string }).deny }
1079    }
1080    const verdict = await guardOutbound($, e, mode)
1081    return verdict === undefined ? next(e) : { deny: (verdict as { deny: string }).deny }
1082  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1083
1084  // What a tool returns: a secret it printed is masked before the model or the transcript gets it.
1085  // The tool has already run, so a failure after it must not run it again: it withholds the output instead.
1086  on('tool.call', { tool: ['Bash', 'Read'] }, async ($, e, next) => {
1087    const r = await next(e)
1088    try {
1089      return await maskOutput($, e, r, mode)
1090    } catch {
1091      return mode === 'monitor' ? r : { deny: say.OUTPUT_FAILURE }
1092    }
1093  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1094
1095  on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
1096    const r = await next(e)
1097    try {
1098      return await maskOutput($, e, r, mode)
1099    } catch {
1100      return mode === 'monitor' ? r : { deny: say.OUTPUT_FAILURE }
1101    }
1102  }).catch(($, e, next) => onFailure(mode, next) ?? next(e))
1103}
1104
1105const displayPathOf = say.displayPath
1106
hooks/commands.ts 655 lines
1// Reads a Bash command as text and says what it is about to do that matters to
2// LeakStop. Pure: no `$`, no I/O.
3//
4// It reads, it does not execute. A command built from variables, a script run
5// with `python -c` or an encoded payload is not understood; the README says so.
6// Everything here errs on the side of looking: a false positive is a question,
7// a false negative is a leak.
8
9import { classifyPath } from './detect.ts'
10
11export type Segment = {
12  /** Words with quotes and escapes resolved. Redirections are words of their own (`>`, `<`). */
13  words: string[]
14  /** Parallel to `words`: true when the word was written with quotes or a backslash (`\\grep`, `"grep"`). */
15  quoted: boolean[]
16  /** Segments joined by `|` share a pipeline id. */
17  pipeline: number
18}
19
20/** Splits on `&&`, `||`, `;`, `&`, `|`, newlines and parentheses; skips heredoc bodies. */
21export function parseCommand(command: string): Segment[] {
22  return parseWithBodies(command).segments
23}
24
25/** `<<'EOF'` bodies are text, not shell: nothing in them is expanded or run. */
26type Span = { start: number; end: number }
27
28function parseWithBodies(command: string): { segments: Segment[]; literalBodies: Span[] } {
29  const segments: Segment[] = []
30  const literalBodies: Span[] = []
31  let words: string[] = []
32  let quoted: boolean[] = []
33  let word = ''
34  let hasWord = false
35  let isQuoted = false
36  let pipeline = 0
37  let heredocs: { delimiter: string; isIndented: boolean; isLiteral: boolean }[] = []
38
39  const pushWord = (text: string, wasQuoted: boolean): void => {
40    words.push(text)
41    quoted.push(wasQuoted)
42  }
43  const endWord = (): void => {
44    if (hasWord) pushWord(word, isQuoted)
45    word = ''
46    hasWord = false
47    isQuoted = false
48  }
49  const endSegment = (isPipe: boolean): void => {
50    endWord()
51    if (words.length > 0) segments.push({ words, quoted, pipeline })
52    words = []
53    quoted = []
54    if (!isPipe) pipeline++
55  }
56
57  let i = 0
58  while (i < command.length) {
59    const char = command[i] as string
60    const next = command[i + 1]
61
62    if (char === '\\') {
63      if (next !== undefined && next !== '\n') {
64        word += next
65        hasWord = true
66        isQuoted = true
67      }
68      i += 2
69      continue
70    }
71    if (char === "'") {
72      const close = command.indexOf("'", i + 1)
73      const end = close < 0 ? command.length : close
74      word += command.slice(i + 1, end)
75      hasWord = true
76      isQuoted = true
77      i = end + 1
78      continue
79    }
80    if (char === '"') {
81      i++
82      hasWord = true
83      isQuoted = true
84      while (i < command.length && command[i] !== '"') {
85        if (command[i] === '\\' && i + 1 < command.length) i++
86        word += command[i]
87        i++
88      }
89      i++
90      continue
91    }
92    if (char === ' ' || char === '\t') {
93      endWord()
94      i++
95      continue
96    }
97    if (char === '\n') {
98      endSegment(false)
99      i++
100      // Skip the bodies of heredocs opened on the line that just ended.
101      for (const heredoc of heredocs) {
102        const start = i
103        while (i < command.length) {
104          const lineEnd = command.indexOf('\n', i)
105          const line = command.slice(i, lineEnd < 0 ? command.length : lineEnd)
106          i = lineEnd < 0 ? command.length : lineEnd + 1
107          if ((heredoc.isIndented ? line.trim() : line) === heredoc.delimiter) break
108        }
109        if (heredoc.isLiteral) literalBodies.push({ start, end: i })
110      }
111      heredocs = []
112      continue
113    }
114    if (char === '&' && word.endsWith('>')) {
115      // `2>&1`: a file-descriptor duplication, not a control operator.
116      word += char
117      i++
118      continue
119    }
120    if (char === '&' && next === '&') {
121      endSegment(false)
122      i += 2
123      continue
124    }
125    if (char === '|' && next === '|') {
126      endSegment(false)
127      i += 2
128      continue
129    }
130    if (char === '|') {
131      endSegment(true)
132      i++
133      continue
134    }
135    if (char === ';' || char === '&' || char === '(' || char === ')' || char === '\x60') {
136      endSegment(false)
137      i++
138      continue
139    }
140    if (char === '<' && next === '<' && command[i + 2] !== '<') {
141      // Heredoc: `<<EOF`, `<<-EOF`, `<<'EOF'`. The body is skipped at the next newline.
142      let j = i + 2
143      const isIndented = command[j] === '-'
144      if (isIndented) j++
145      while (command[j] === ' ') j++
146      const quote = command[j] === "'" || command[j] === '"' ? command[j] : undefined
147      const isLiteral = quote !== undefined || command[j] === '\\'
148      if (quote !== undefined) j++
149      let delimiter = ''
150      while (j < command.length && !/[\s;&|()<>'"]/.test(command[j] as string)) delimiter += command[j++]
151      if (quote !== undefined && command[j] === quote) j++
152      endWord()
153      pushWord('<<', false)
154      if (delimiter !== '') heredocs.push({ delimiter, isIndented, isLiteral })
155      i = j
156      continue
157    }
158    if (char === '<' || (char === '>' && !/^\d+$/.test(word))) {
159      endWord()
160      let op = char
161      i++
162      while (command[i] === char || (char === '>' && command[i] === '|')) op += command[i++]
163      if (char === '>' && command[i] === '&') {
164        // `>&2`: duplicate a descriptor.
165        op += command[i++]
166        while (/[0-9-]/.test(command[i] ?? '')) op += command[i++]
167      }
168      pushWord(op, false)
169      continue
170    }
171    word += char
172    hasWord = true
173    i++
174  }
175  endSegment(false)
176  return { segments, literalBodies }
177}
178
179// --- Programs --------------------------------------------------------------
180
181const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
182const WRAPPERS = new Set(['sudo', 'command', 'time', 'nohup', 'exec', 'builtin', 'nice', 'env'])
183
184type Program = { name: string; args: string[]; isBareEnv: boolean; /** Invoked by its bare name, typed with no quotes or backslash and no wrapper before it. */ isPlain: boolean }
185
186/** The program a segment runs, past assignments and wrappers (`sudo`, `env FOO=1`, `time`). */
187function programOf(words: readonly string[], quoted: readonly boolean[] = []): Program {
188  let i = 0
189  let isEnv = false
190  for (;;) {
191    const word = words[i]
192    if (word === undefined) break
193    if (ASSIGNMENT.test(word)) {
194      i++
195    } else if (WRAPPERS.has(word)) {
196      if (word === 'env') isEnv = true
197      i++
198      while (words[i]?.startsWith('-') === true) i++
199    } else {
200      break
201    }
202  }
203  const first = words[i]
204  if (first === undefined) return { name: '', args: [], isBareEnv: isEnv, isPlain: false }
205  return { name: first.split('/').pop() ?? first, args: words.slice(i + 1), isBareEnv: false, isPlain: i === 0 && !first.includes('/') && quoted[i] !== true }
206}
207
208/** The files a segment sends its standard output to with `>` or `>>`. */
209function outputTargets(args: readonly string[]): string[] {
210  const targets: string[] = []
211  for (let i = 0; i < args.length; i++) {
212    if (/^>>?\|?$/.test(args[i] as string) && args[i + 1] !== undefined) targets.push(args[++i] as string)
213  }
214  return targets
215}
216
217const isFlag = (word: string): boolean => word.startsWith('-') && word.length > 1
218
219/** Words that are operands: not flags and not the target of a redirection. */
220function operands(args: readonly string[]): string[] {
221  const out: string[] = []
222  for (let i = 0; i < args.length; i++) {
223    const word = args[i] as string
224    if (/^>>?\|?$/.test(word)) {
225      i++ // the target of an output redirection is written, not read
226      continue
227    }
228    if (word === '<') continue // the next word is read: keep it
229    if (word === '<<' || isFlag(word)) continue
230    out.push(word)
231  }
232  return out
233}
234
235// --- Facts -----------------------------------------------------------------
236
237/** Programs that print a file's contents, values included. `sed`, `awk` and `cut` are left out: they are how names are listed. */
238const VIEWERS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'tac', 'strings', 'xxd', 'od', 'hexdump', 'grep', 'egrep', 'fgrep', 'rg', 'ag'])
239/** The viewers whose output is the file itself, so listing names only is a faithful replacement. */
240const PLAIN_VIEWERS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'tac'])
241
242const SECRET_NAME = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|AUTH|PRIVATE)(?:_|$)|APIKEY/i
243
244export const isSecretName = (name: string): boolean => SECRET_NAME.test(name)
245
246/**
247 * Sensitive by path, or a glob that would match a sensitive path (`.env*`,
248 * `*.pem`): the shell expands it, so the pattern itself is what the command shows.
249 */
250function isSensitiveOperand(operand: string): boolean {
251  if (classifyPath(operand) !== undefined) return true
252  if (!/[*?[]/.test(operand)) return false
253  return [operand.replace(/[*?]/g, ''), operand.replace(/[*?]/g, 'x'), operand.replace(/\*/g, '.x')].some((guess) => classifyPath(guess) !== undefined)
254}
255
256/** A recursive `grep` or `rg` that prints lines (not just file names or counts). */
257export type Search = {
258  /** Where it looks: the folders it was given, or `.` when it was given none. */
259  dirs: string[]
260  /** Patterns of files it is told to skip (`--exclude`, `--exclude-dir`, `-g '!…'`). */
261  excludes: string[]
262  /** Patterns of files it is limited to (`--include`, `-g`); empty when it looks at everything. */
263  includes: string[]
264  /**
265   * Files that git ignores are not searched. True for `rg` and `ag` unless told otherwise, and for the
266   * plain `grep` command, which Claude Code's shell replaces with a search that honours `.gitignore`.
267   */
268  respectsIgnore: boolean
269}
270
271const SEARCHERS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag'])
272/** Flags whose value is the next word. */
273const VALUE_FLAGS = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-d', '-D', '-g', '-t', '-T', '-j', '-M', '-E', '--include', '--exclude', '--exclude-dir', '--exclude-from', '--file', '--regexp', '--max-count', '--glob', '--iglob', '--type', '--type-not', '--threads', '--max-depth', '--context', '--before-context', '--after-context', '--directories', '--devices'])
274/** `rg` and `ag` skip hidden and ignored files unless told otherwise. */
275/** Flags that stop a search from honouring `.gitignore`. */
276const NO_IGNORE = /^(?:--no-ignore(?:-[a-z-]+)?|--unrestricted|-u+|-U)$/
277const REACHES_HIDDEN = /^(?:--hidden|-\.|--no-ignore(?:-[a-z-]+)?|--unrestricted|-u+|-U)$/
278/** Flags that make the output file names or counts, never lines. */
279const LIST_ONLY_LONG = /^--(?:files-with-matches|files-without-match|count|count-matches|quiet|silent|files)$/
280
281type SearchParse = {
282  /** The patterns and paths, with every flag and flag value removed. */
283  paths: string[]
284  /** File names or counts only: nothing here can print a line of a file. */
285  isListOnly: boolean
286  /** Set when the search is recursive and can reach files nobody named. */
287  search?: Search
288}
289
290function parseSearch(name: string, args: readonly string[], isPlain: boolean): SearchParse {
291  const isGrep = name === 'grep' || name === 'egrep' || name === 'fgrep'
292  let isRecursive = !isGrep // rg and ag always are
293  let reachesHidden = isGrep // grep reads dotfiles and ignored files
294  let isIgnoreOff = false
295  let isPatternGiven = false
296  let isListOnly = false
297  const words: string[] = []
298  const excludes: string[] = []
299  const includes: string[] = []
300
301  for (let i = 0; i < args.length; i++) {
302    const arg = args[i] as string
303    if (/^>>?\|?$/.test(arg)) {
304      i++ // the target of an output redirection
305      continue
306    }
307    if (arg === '<' || arg === '<<') continue
308    if (!isFlag(arg)) {
309      words.push(arg)
310      continue
311    }
312    const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined]
313    const value = inline ?? (VALUE_FLAGS.has(flag) ? args[++i] : undefined)
314
315    if (LIST_ONLY_LONG.test(flag)) isListOnly = true
316    if (/^-[A-Za-z]*[lLcq][A-Za-z]*$/.test(flag) && !flag.startsWith('--') && !VALUE_FLAGS.has(flag)) isListOnly = true
317    if (flag === '--recursive' || flag === '--dereference-recursive') isRecursive = true
318    else if (isGrep && !flag.startsWith('--') && /^-[A-Za-z]*[rR][A-Za-z]*$/.test(flag) && !VALUE_FLAGS.has(flag)) isRecursive = true
319    if (isGrep && (flag === '-d' || flag === '--directories') && value === 'recurse') isRecursive = true
320    if (!isGrep && REACHES_HIDDEN.test(flag)) reachesHidden = true
321    if (NO_IGNORE.test(flag)) isIgnoreOff = true
322    if (flag === '-e' || flag === '-f' || flag === '--regexp' || flag === '--file') isPatternGiven = true
323
324    if (value !== undefined) {
325      if (flag === '--exclude' || flag === '--exclude-from') excludes.push(value)
326      else if (flag === '--exclude-dir') excludes.push(`**/${value}/**`)
327      else if (flag === '--include') includes.push(value)
328      else if (flag === '-g' || flag === '--glob' || flag === '--iglob') (value.startsWith('!') ? excludes : includes).push(value.replace(/^!/, ''))
329    }
330  }
331  const paths = isPatternGiven ? words : words.slice(1)
332  if (isListOnly || !isRecursive || !reachesHidden) return { paths, isListOnly }
333  const respectsIgnore = !isIgnoreOff && (isGrep ? name === 'grep' && isPlain : true)
334  return { paths, isListOnly, search: { dirs: paths.length > 0 ? paths : ['.'], excludes, includes, respectsIgnore } }
335}
336
337export type GitOp =
338  | { kind: 'add'; isAll: boolean; paths: string[]; dir?: string }
339  | { kind: 'commit'; isAll: boolean; dir?: string; staging: { isAll: boolean; paths: string[] }[] }
340  | { kind: 'push'; dir?: string }
341
342export type CommandFacts = {
343  /** Files a viewer would print that are sensitive by path (content check pending for `requiresToken` ones). */
344  readFiles: string[]
345  /** `printenv`, `env`, `export -p`, `set` or the environ file under /proc: the whole environment. */
346  isEnvDump: boolean
347  /** Secret-looking variables printed one by one: `printenv API_KEY`, `echo $TOKEN`. */
348  secretVars: string[]
349  git: GitOp[]
350  /** The command changes `.leakstop.json`. */
351  touchesConfig: boolean
352  /** The command that prints names only instead, when the command is simple enough to rewrite. */
353  namesOnly?: string
354  /** Recursive searches that print matching lines and may reach files nobody named. */
355  searches: Search[]
356  /**
357   * Where the command writes, when it does nothing but write: one `echo`, `printf` or `cat`
358   * redirected to files, with no pipe, no `&&` and no command substitution. A secret in
359   * such a command goes to those files and nowhere else.
360   */
361  writeTargets?: string[]
362}
363
364/**
365 * Prints `NAME=<hidden>` for each assignment and nothing else, so no value and no
366 * continuation line escapes. A name must be a plausible variable name (all upper
367 * or all lower case, 48 characters at most): a base64 line that happens to end in
368 * `=` inside a multi-line value is not one, and printing it would leak key material.
369 */
370const NAMES_ONLY_SED = String.raw`sed -nE 's/^[[:space:]]*(export[[:space:]]+)?([A-Z_][A-Z0-9_]{0,47}|[a-z_][a-z0-9_]{0,47})=.*/\2=<hidden>/p'`
371
372export const shellQuote = (text: string): string => "'" + text.split("'").join("'\\''") + "'"
373
374const READ_ONLY = new Set(['cat', 'less', 'more', 'head', 'tail', 'grep', 'egrep', 'rg', 'ls', 'stat', 'wc', 'file', 'diff', 'jq', 'test', '['])
375
376function touchesConfig(segments: readonly Segment[]): boolean {
377  return segments.some((segment) => {
378    if (!segment.words.some((word) => word.includes('.leakstop.json'))) return false
379    const program = programOf(segment.words)
380    const isWrite = segment.words.some((word) => /^\d*>>?\|?$/.test(word))
381    const isReadOnlyGit = program.name === 'git' && /^(?:diff|log|show|status|check-ignore|blame|ls-files)$/.test(program.args.find((a) => !isFlag(a)) ?? '')
382    return isWrite || !(READ_ONLY.has(program.name) || isReadOnlyGit)
383  })
384}
385
386const BACKTICKS = new RegExp('\\x60([^\\x60]*)\\x60', 'g')
387
388/** Text of command substitutions (dollar-parenthesis and backticks), which can hide a command inside quotes. */
389function substitutions(command: string): string[] {
390  // The body of `<<'EOF'` is never expanded, so a `$(…)` or backticks in it are only text.
391  let text = ''
392  let at = 0
393  for (const { start, end } of parseWithBodies(command).literalBodies) {
394    text += command.slice(at, start)
395    at = end
396  }
397  text += command.slice(at)
398  const out: string[] = []
399  for (const match of text.matchAll(/\$\(([^()]*)\)/g)) if (match[1] !== undefined) out.push(match[1])
400  for (const match of text.matchAll(BACKTICKS)) if (match[1] !== undefined) out.push(match[1])
401  return out
402}
403
404function gitOps(segments: readonly Segment[]): GitOp[] {
405  const ops: GitOp[] = []
406  const staging: { isAll: boolean; paths: string[] }[] = []
407  let dir: string | undefined
408  for (const segment of segments) {
409    const program = programOf(segment.words)
410    if (program.name === 'cd') {
411      const target = program.args.find((a) => !isFlag(a))
412      if (target !== undefined && !/^[~$-]/.test(target)) dir = dir === undefined || target.startsWith('/') ? target : `${dir}/${target}`
413      continue
414    }
415    if (program.name !== 'git') continue
416    // Global options before the subcommand: -C <dir>, -c k=v, --no-pager, ...
417    let gitDir = dir
418    const args = program.args
419    let i = 0
420    while (i < args.length && isFlag(args[i] as string)) {
421      const flag = args[i] as string
422      if (flag === '-C' && args[i + 1] !== undefined) {
423        const target = args[i + 1] as string
424        gitDir = gitDir === undefined || target.startsWith('/') ? target : `${gitDir}/${target}`
425        i += 2
426      } else if (flag === '-c' || flag === '--git-dir' || flag === '--work-tree') {
427        i += 2
428      } else {
429        i++
430      }
431    }
432    const sub = args[i]
433    const rest = args.slice(i + 1)
434    const withDir = <T extends GitOp>(op: T): T => (gitDir === undefined ? op : { ...op, dir: gitDir })
435    if (sub === 'add') {
436      const paths = operands(rest)
437      const isAll = rest.some((a) => a === '-A' || a === '--all' || a === '-u' || a === '--update') || paths.some((p) => p === '.' || p === ':/' || p === '*' || p === './')
438      staging.push({ isAll, paths })
439      ops.push(withDir({ kind: 'add', isAll, paths }))
440    } else if (sub === 'commit') {
441      const isAll = rest.some((a) => a === '--all' || /^-[A-Za-z]*a[A-Za-z]*$/.test(a))
442      ops.push(withDir({ kind: 'commit', isAll, staging: [...staging] }))
443    } else if (sub === 'push') {
444      ops.push(withDir({ kind: 'push' }))
445    }
446  }
447  return ops
448}
449
450/**
451 * `git` subcommands that print what a file holds or held: a blob, a patch or annotated lines.
452 * `git diff` is left out: on an ignored or untracked `.env`, the usual case, it prints nothing.
453 */
454const GIT_PRINTERS = new Set(['show', 'cat-file', 'blame', 'grep'])
455/** `git log` prints file contents only with a patch. */
456const GIT_LOG_PATCH = /^(?:-p|-u|--patch|--patch-with-stat|--patch-with-raw|-L.*|--full-diff)$/
457
458/**
459 * Sensitive files a `git` command prints from history or the index: `git show HEAD:.env`,
460 * `git cat-file -p main:.env`, `git log -p -- .env`.
461 */
462function gitPrintedFiles(args: readonly string[]): string[] {
463  let i = 0
464  while (i < args.length && isFlag(args[i] as string)) i += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[i] as string) ? 2 : 1
465  const sub = args[i]
466  const rest = args.slice(i + 1)
467  if (sub === undefined || !(GIT_PRINTERS.has(sub) || (sub === 'log' && rest.some((a) => GIT_LOG_PATCH.test(a))))) return []
468  const files: string[] = []
469  for (const word of operands(rest)) {
470    // `<rev>:<path>` or `:<path>` (the index); a bare word is a path or a revision.
471    const colon = word.indexOf(':')
472    const path = colon >= 0 && !word.includes('://') ? word.slice(colon + 1) : word
473    if (path !== '' && isSensitiveOperand(path)) files.push(path)
474  }
475  return files
476}
477
478/** The name patterns a `find` matches and the program its `-exec` runs, if any. */
479function findFacts(args: readonly string[]): { patterns: string[]; runs?: string } {
480  const patterns: string[] = []
481  let runs: string | undefined
482  for (let i = 0; i < args.length; i++) {
483    const word = args[i] as string
484    if (['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'].includes(word) && args[i + 1] !== undefined) patterns.push(args[++i] as string)
485    else if (['-exec', '-execdir', '-ok', '-okdir'].includes(word) && args[i + 1] !== undefined && runs === undefined) runs = programOf(args.slice(i + 1)).name
486  }
487  return runs === undefined ? { patterns } : { patterns, runs }
488}
489
490const dedupe = <T>(items: readonly T[]): T[] => {
491  const seen = new Set<string>()
492  return items.filter((item) => {
493    const key = JSON.stringify(item)
494    return seen.has(key) ? false : (seen.add(key), true)
495  })
496}
497
498function analyzeSegments(command: string, segments: readonly Segment[], depth: number): CommandFacts {
499  const readFiles: string[] = []
500  const secretVars: string[] = []
501  let isEnvDump = false
502  const searches: Search[] = []
503  /** Files this command fills with the value of a secret variable, and which variables. */
504  const written = new Map<string, string[]>()
505
506  for (const segment of segments) {
507    const { name, args, isBareEnv, isPlain } = programOf(segment.words, segment.quoted)
508    if (written.size > 0 && (VIEWERS.has(name) || name === 'sed' || name === 'awk')) {
509      for (const file of operands(args)) secretVars.push(...(written.get(file) ?? []))
510    }
511    const isFiltered = segments.some(
512      (other) => other !== segment && other.pipeline === segment.pipeline && ['cut', 'wc'].includes(programOf(other.words).name),
513    )
514
515    if (isBareEnv || (name === 'printenv' && operands(args).length === 0)) {
516      if (!isFiltered) isEnvDump = true
517    } else if (name === 'printenv') {
518      for (const variable of operands(args)) if (isSecretName(variable)) secretVars.push(variable)
519    } else if ((name === 'export' || name === 'declare' || name === 'typeset') && args.some((a) => /^-[a-z]*[px][a-z]*$/.test(a)) && operands(args).length === 0) {
520      if (!isFiltered) isEnvDump = true
521    } else if (name === 'set' && args.length === 0) {
522      if (!isFiltered) isEnvDump = true
523    } else if (name === 'echo' || name === 'printf') {
524      const printed: string[] = []
525      for (const match of segment.words.join(' ').matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g)) {
526        if (match[1] !== undefined && isSecretName(match[1])) printed.push(match[1])
527      }
528      // Redirected to a file, the value is not printed; reading that file back in the same command is.
529      const targets = outputTargets(args)
530      if (targets.length === 0) secretVars.push(...printed)
531      else if (printed.length > 0) for (const target of targets) written.set(target, printed)
532    } else if (SEARCHERS.has(name)) {
533      const parsed = parseSearch(name, args, isPlain)
534      if (parsed.search !== undefined) searches.push(parsed.search)
535      // A search that names a sensitive file is a plain read of it, unless it only lists names or counts.
536      if (!parsed.isListOnly) {
537        for (const file of parsed.paths) {
538          if (/^\/proc\/[^/]+\/environ$/.test(file)) isEnvDump = true
539          else if (isSensitiveOperand(file)) readFiles.push(file)
540        }
541      }
542    } else if (VIEWERS.has(name)) {
543      for (const file of operands(args)) {
544        if (/^\/proc\/[^/]+\/environ$/.test(file)) isEnvDump = true
545        else if (isSensitiveOperand(file)) readFiles.push(file)
546      }
547    } else if (name === 'git') {
548      readFiles.push(...gitPrintedFiles(args))
549    } else if (name === 'find') {
550      const { patterns, runs } = findFacts(args)
551      if (runs !== undefined && VIEWERS.has(runs)) readFiles.push(...patterns.filter(isSensitiveOperand))
552    } else if (name === 'xargs') {
553      // `find -name .env | xargs cat`: the files come from a `find` earlier in the same pipeline.
554      const runs = programOf(args.filter((a) => !isFlag(a))).name
555      if (VIEWERS.has(runs)) {
556        for (const other of segments) {
557          if (other === segment || other.pipeline !== segment.pipeline) continue
558          const source = programOf(other.words)
559          if (source.name === 'find') readFiles.push(...findFacts(source.args).patterns.filter(isSensitiveOperand))
560        }
561      }
562    }
563  }
564
565  let git = gitOps(segments)
566  let touches = touchesConfig(segments)
567
568  if (depth < 2) {
569    for (const inner of substitutions(command)) {
570      const facts = analyzeSegments(inner, parseCommand(inner), depth + 1)
571      readFiles.push(...facts.readFiles)
572      secretVars.push(...facts.secretVars)
573      isEnvDump ||= facts.isEnvDump
574      searches.push(...facts.searches)
575      git = [...git, ...facts.git]
576      touches ||= facts.touchesConfig
577    }
578  }
579
580  return { readFiles: [...new Set(readFiles)], isEnvDump, secretVars: [...new Set(secretVars)], searches: dedupe(searches), git, touchesConfig: touches, namesOnly: namesOnlyFor(segments, readFiles, isEnvDump) }
581}
582
583/** The replacement that lists names only, for a command that is one plain view of env files or the bare environment. */
584function namesOnlyFor(segments: readonly Segment[], readFiles: readonly string[], isEnvDump: boolean): string | undefined {
585  if (segments.length !== 1) return undefined
586  const { name, args, isBareEnv } = programOf((segments[0] as Segment).words)
587  if (isEnvDump && (isBareEnv || name === 'printenv') && operands(args).length === 0) return `env | ${NAMES_ONLY_SED}`
588  if (readFiles.length > 0 && PLAIN_VIEWERS.has(name)) {
589    const others = operands(args).filter((word) => !/^\d+$/.test(word))
590    if (others.length === readFiles.length && readFiles.every((file) => classifyPath(file)?.kind === 'env')) {
591      return `${NAMES_ONLY_SED} ${readFiles.map((file) => shellQuote(file.startsWith('-') ? `./${file}` : file)).join(' ')}`
592    }
593  }
594  return undefined
595}
596
597const WRITERS = new Set(['echo', 'printf', 'cat'])
598
599/** `sed` options that edit the file in place: `-i`, `-i.bak`, `-Ei`, `--in-place`. */
600const isInPlace = (word: string): boolean => /^-[EnrsuzS]*i/.test(word) || word === '--in-place' || word.startsWith('--in-place=')
601
602/** The files `sed -i` rewrites, or `undefined` when it is not an in-place edit of named files. */
603function inPlaceTargets(args: readonly string[]): string[] | undefined {
604  if (!args.some(isInPlace)) return undefined
605  const words: string[] = []
606  for (let i = 0; i < args.length; i++) {
607    const word = args[i] as string
608    if (word === '-i' && args[i + 1] === '') i++ // macOS: the backup suffix is its own, empty, word
609    else if (word === '-e' || word === '-f') {
610      words.push('-e')
611      i++
612    } else if (!isFlag(word) || word === '-') words.push(word)
613  }
614  const script = words.includes('-e') ? 0 : 1
615  const targets = words.filter((w) => w !== '-e').slice(script)
616  return targets.length > 0 ? targets : undefined
617}
618
619/** True when a `tee` segment sends its standard output to /dev/null, so it writes the files and prints nothing. */
620const isQuietTee = (args: readonly string[]): boolean => {
621  for (let i = 0; i < args.length; i++) {
622    if (/^>>?$/.test(args[i] as string) && args[i + 1] === '/dev/null') return true
623  }
624  return false
625}
626
627/** The files a command only writes to, or `undefined` when it does anything else as well. */
628function writeTargetsOf(command: string, segments: readonly Segment[]): string[] | undefined {
629  if (substitutions(command).length > 0) return undefined
630  if (segments.length === 1) {
631    const { name, args } = programOf((segments[0] as Segment).words)
632    if (name === 'sed') return inPlaceTargets(args)
633    if (!WRITERS.has(name)) return undefined
634    const targets = outputTargets(args)
635    return targets.length > 0 ? targets : undefined
636  }
637  // `echo … | tee -a file > /dev/null`: tee prints what it writes, so only the quiet form is a plain write.
638  if (segments.length === 2 && (segments[0] as Segment).pipeline === (segments[1] as Segment).pipeline) {
639    const writer = programOf((segments[0] as Segment).words)
640    const tee = programOf((segments[1] as Segment).words)
641    if (!['echo', 'printf'].includes(writer.name) || outputTargets(writer.args).length > 0) return undefined
642    if (tee.name !== 'tee' || !isQuietTee(tee.args)) return undefined
643    const targets = operands(tee.args).filter((word) => word !== '/dev/null')
644    return targets.length > 0 ? targets : undefined
645  }
646  return undefined
647}
648
649export function analyzeCommand(command: string): CommandFacts {
650  const segments = parseCommand(command)
651  const facts = analyzeSegments(command, segments, 0)
652  const writeTargets = writeTargetsOf(command, segments)
653  return writeTargets === undefined ? facts : { ...facts, writeTargets }
654}
655
hooks/config.ts 248 lines
1// `.leakstop.json`: parsing and validation. Pure: no `$`, no I/O.
2//
3// The file comes from the repository, so it is untrusted: a cloned project must
4// not be able to lower the protection or slow the scanner down. Whatever is
5// wrong with it is ignored with a warning, and ignoring a setting always means
6// the stricter default. The file can only add rules or relax medium findings;
7// the protection level itself (`mode`) lives in the user's own settings.
8
9import type { StoredConfig, StoredRule } from '../types'
10import type { Rule, Severity } from './rules.ts'
11
12/** A config file larger than this is ignored. */
13export const MAX_CONFIG_CHARS = 256 * 1024
14
15const MAX_IGNORE_PATHS = 200
16const MAX_ALLOW_FINGERPRINTS = 500
17const MAX_CUSTOM_RULES = 50
18const MAX_PATTERN_CHARS = 200
19const MAX_REGEX_CHARS = 200
20const KNOWN_KEYS = new Set(['ignorePaths', 'allowFingerprints', 'customRules'])
21const KNOWN_RULE_KEYS = new Set(['id', 'regex', 'severity', 'label', 'prefix', 'group', 'minEntropy'])
22
23export const EMPTY_CONFIG: StoredConfig = { ignorePaths: [], allowFingerprints: [], customRules: [], warnings: [] }
24
25// --- Globs for ignorePaths -------------------------------------------------
26
27/**
28 * `tests/fixtures/**`, `*.json`, `examples/`. A pattern with no slash matches at any
29 * depth, as in .gitignore; `**` crosses folders, `*` and `?` do not.
30 */
31export function globToRegExp(glob: string): RegExp | undefined {
32  const pattern = glob.replace(/\\/g, '/').replace(/^\.\//, '')
33  if (pattern === '' || pattern.length > MAX_PATTERN_CHARS) return undefined
34  if ((pattern.match(/\*\*/g) ?? []).length > 3) return undefined
35
36  let source = ''
37  for (let i = 0; i < pattern.length; i++) {
38    const char = pattern[i] as string
39    if (char === '*' && pattern[i + 1] === '*') {
40      const isFolder = pattern[i + 2] === '/'
41      source += isFolder ? '(?:.*/)?' : '.*'
42      i += isFolder ? 2 : 1
43    } else if (char === '*') {
44      source += '[^/]*'
45    } else if (char === '?') {
46      source += '[^/]'
47    } else {
48      source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
49    }
50  }
51  if (pattern.endsWith('/')) source += '.*'
52  const anywhere = pattern.includes('/') ? '' : '(?:.*/)?'
53  return new RegExp(`^${anywhere}${source}$`)
54}
55
56/** A longer path never matches: globs come from the repository and can be slow on a very long path, and ignoring a path only ever relaxes. */
57const MAX_PATH_CHARS = 512
58
59/** True when `path` matches any pattern. Patterns that do not compile match nothing. */
60export function matchesAny(path: string, patterns: readonly string[]): boolean {
61  if (path.length > MAX_PATH_CHARS) return false
62  const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '')
63  return patterns.some((pattern) => globToRegExp(pattern)?.test(normalized) === true)
64}
65
66// --- Custom rules ------------------------------------------------------------
67
68/** Walks a regex source and says whether a group holding an unbounded repeat is itself repeated without bound. */
69function hasNestedQuantifier(source: string): boolean {
70  const stack: boolean[] = []
71  let unbounded = 0
72  let i = 0
73  const isUnboundedAt = (index: number): boolean => {
74    const char = source[index]
75    if (char === '*' || char === '+') return true
76    if (char === '{') return /^\{\d+,\}/.test(source.slice(index))
77    return false
78  }
79  while (i < source.length) {
80    const char = source[i] as string
81    if (char === '\\') {
82      i += 2
83      continue
84    }
85    if (char === '[') {
86      i++
87      while (i < source.length && source[i] !== ']') i += source[i] === '\\' ? 2 : 1
88      i++ // the quantifier after the class, if any, is counted on the next turn
89      continue
90    }
91    if (char === '(') {
92      stack.push(false)
93    } else if (char === ')') {
94      const held = stack.pop() ?? false
95      if (held && isUnboundedAt(i + 1)) return true
96      if (held && stack.length > 0) stack[stack.length - 1] = true
97    } else if (isUnboundedAt(i) && i > 0) {
98      unbounded++
99      if (stack.length > 0) stack[stack.length - 1] = true
100    }
101    i++
102  }
103  // `.*.*.*` and friends are slow without being nested.
104  return unbounded > 4
105}
106
107const HOSTILE_INPUTS: readonly string[] = [
108  'a'.repeat(4000),
109  'ab'.repeat(2000),
110  ' '.repeat(4000),
111  '0123456789abcdef'.repeat(250),
112  `${'a'.repeat(22)}!`,
113  `${'a '.repeat(11)}!`,
114  `${'ab'.repeat(11)}!`,
115]
116const BUDGET_MS = 40
117
118/**
119 * Why a custom regex cannot be used, or `undefined` when it can. It must be
120 * short, free of backreferences and lookarounds, free of nested unbounded
121 * repeats, and quick on hostile inputs: a rule that makes the scanner run out of
122 * its 10 seconds would make Claude Code skip the hook and let the call through.
123 */
124export function regexProblem(source: string): string | undefined {
125  if (source.length === 0 || source.length > MAX_REGEX_CHARS) return `must be 1 to ${MAX_REGEX_CHARS} characters`
126  if (/\\[1-9k]/.test(source)) return 'backreferences are not allowed'
127  if (/\(\?<?[=!]/.test(source)) return 'lookaheads and lookbehinds are not allowed'
128  let regex: RegExp
129  try {
130    regex = new RegExp(source, 'g')
131  } catch {
132    return 'is not a valid regular expression'
133  }
134  if (regex.test('')) return 'matches the empty string'
135  if (hasNestedQuantifier(source)) return 'repeats a repeat (for example (a+)+), which can run for ever'
136  for (const input of HOSTILE_INPUTS) {
137    const startedAt = performance.now()
138    input.match(regex)
139    if (performance.now() - startedAt > BUDGET_MS) return 'is too slow on long input'
140  }
141  return undefined
142}
143
144function parseRule(raw: unknown, index: number, seen: Set<string>): { rule?: StoredRule; warning?: string } {
145  const at = `customRules[${index}]`
146  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { warning: `${at} is not an object, so it was ignored` }
147  const entry = raw as Record<string, unknown>
148  const unknown = Object.keys(entry).filter((key) => !KNOWN_RULE_KEYS.has(key))
149  if (unknown.length > 0) return { warning: `${at} has unknown fields (${unknown.join(', ')}), so it was ignored` }
150
151  const { id, regex, severity, label, prefix, group, minEntropy } = entry
152  if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(id)) return { warning: `${at}.id must be lowercase letters, digits and dashes (40 at most), so the rule was ignored` }
153  if (seen.has(id)) return { warning: `${at}.id "${id}" is used twice, so the second rule was ignored` }
154  if (typeof regex !== 'string') return { warning: `${at}.regex must be a string, so the rule was ignored` }
155  if (severity !== 'critical' && severity !== 'medium') return { warning: `${at}.severity must be "critical" or "medium", so the rule was ignored` }
156  const problem = regexProblem(regex)
157  if (problem !== undefined) return { warning: `${at}.regex ${problem}, so the rule was ignored` }
158
159  const rule: StoredRule = { id: `custom:${id}`, label: typeof label === 'string' && label.length > 0 ? label.slice(0, 60) : `Custom rule ${id}`, severity, source: regex }
160  if (typeof prefix === 'string' && prefix.length > 0 && prefix.length <= 20) rule.prefix = prefix
161  if (typeof group === 'number' && Number.isInteger(group) && group >= 1 && group <= 9) rule.groups = [group]
162  if (typeof minEntropy === 'number' && minEntropy >= 0 && minEntropy <= 6) rule.minEntropy = minEntropy
163  seen.add(id)
164  return { rule }
165}
166
167/** A stored rule as the scanner takes it. `undefined` when it no longer compiles. */
168export function toRule(stored: StoredRule): Rule | undefined {
169  try {
170    const rule: Rule = { id: stored.id, label: stored.label, severity: stored.severity as Severity, regex: new RegExp(stored.source, 'g') }
171    if (stored.prefix !== undefined) rule.prefix = stored.prefix
172    if (stored.groups !== undefined) rule.groups = stored.groups
173    if (stored.minEntropy !== undefined) rule.minEntropy = stored.minEntropy
174    return rule
175  } catch {
176    return undefined
177  }
178}
179
180// --- The file ----------------------------------------------------------------
181
182const FINGERPRINT = /^sha256:[0-9a-f]{16}$/
183
184function stringList(value: unknown, field: string, max: number, warnings: string[], accept: (item: string) => boolean, why: string): string[] {
185  if (!Array.isArray(value)) {
186    warnings.push(`${field} must be a list of strings, so it was ignored`)
187    return []
188  }
189  const kept: string[] = []
190  let dropped = 0
191  for (const item of value.slice(0, max)) {
192    if (typeof item === 'string' && accept(item)) kept.push(item)
193    else dropped++
194  }
195  if (value.length > max) warnings.push(`${field} has more than ${max} entries; the rest were ignored`)
196  if (dropped > 0) warnings.push(`${field}: ${dropped} entr${dropped === 1 ? 'y' : 'ies'} ignored (${why})`)
197  return [...new Set(kept)]
198}
199
200/**
201 * Reads the text of `.leakstop.json`. Never throws: a malformed file gives the
202 * defaults and a warning, an unknown or malformed field is ignored with a
203 * warning, and the valid fields are kept.
204 */
205export function parseConfig(text: string): StoredConfig {
206  if (text.length > MAX_CONFIG_CHARS) return { ...EMPTY_CONFIG, warnings: ['.leakstop.json is larger than 256 KiB, so it was ignored'] }
207  let parsed: unknown
208  try {
209    parsed = JSON.parse(text)
210  } catch {
211    return { ...EMPTY_CONFIG, warnings: ['.leakstop.json is not valid JSON, so the defaults apply'] }
212  }
213  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
214    return { ...EMPTY_CONFIG, warnings: ['.leakstop.json must hold a JSON object, so the defaults apply'] }
215  }
216
217  const root = parsed as Record<string, unknown>
218  const warnings: string[] = []
219  const unknown = Object.keys(root).filter((key) => !KNOWN_KEYS.has(key))
220  if (unknown.length > 0) warnings.push(`unknown field${unknown.length === 1 ? '' : 's'} ignored: ${unknown.join(', ')}`)
221
222  const ignorePaths =
223    root.ignorePaths === undefined
224      ? []
225      : stringList(root.ignorePaths, 'ignorePaths', MAX_IGNORE_PATHS, warnings, (item) => globToRegExp(item) !== undefined, 'empty, too long or too many **')
226  const allowFingerprints =
227    root.allowFingerprints === undefined
228      ? []
229      : stringList(root.allowFingerprints, 'allowFingerprints', MAX_ALLOW_FINGERPRINTS, warnings, (item) => FINGERPRINT.test(item), 'not a sha256: fingerprint')
230
231  const customRules: StoredRule[] = []
232  if (root.customRules !== undefined) {
233    if (!Array.isArray(root.customRules)) {
234      warnings.push('customRules must be a list, so it was ignored')
235    } else {
236      if (root.customRules.length > MAX_CUSTOM_RULES) warnings.push(`customRules has more than ${MAX_CUSTOM_RULES} rules; the rest were ignored`)
237      const seen = new Set<string>()
238      root.customRules.slice(0, MAX_CUSTOM_RULES).forEach((raw, index) => {
239        const { rule, warning } = parseRule(raw, index, seen)
240        if (rule !== undefined) customRules.push(rule)
241        if (warning !== undefined) warnings.push(warning)
242      })
243    }
244  }
245
246  return { ignorePaths, allowFingerprints, customRules, warnings }
247}
248
hooks/detect.ts 277 lines
1// Detection: path rules, regexes, entropy, allowlist and false-positive
2// exclusions. Pure functions: no `$`, no I/O, no state.
3//
4// A Finding carries the secret's `value` so the caller can fingerprint and mask
5// it. It must never be stored, logged or sent anywhere: turn it into a
6// MaskedFinding with `describe` (mask.ts) and drop it.
7
8import { NPMRC_RULES, PYPIRC_RULES, RULES } from './rules.ts'
9import type { Rule, Severity } from './rules.ts'
10
11export type { Rule, Severity } from './rules.ts'
12
13/** `$.fs.read` accepts 4 MiB; anything larger is skipped, not scanned. */
14export const MAX_SCAN_CHARS = 4 * 1024 * 1024
15
16export type Finding = {
17  ruleId: string
18  label: string
19  severity: Severity
20  /** 1-based, relative to the scanned text. */
21  line: number
22  start: number
23  end: number
24  /** The secret itself. In memory only. */
25  value: string
26  prefix?: string
27  isGeneric: boolean
28}
29
30export type ScanResult = {
31  findings: Finding[]
32  /** True when the text was over MAX_SCAN_CHARS and nothing was scanned. */
33  isSkipped: boolean
34  /** True when the custom rules ran out of their time budget and did not cover all the text. */
35  isPartial: boolean
36}
37
38export type ScanOptions = {
39  /** Path of the file the text is going to, when there is one. */
40  path?: string
41  /** Extra rules, already validated by the caller. */
42  extraRules?: readonly Rule[]
43}
44
45// --- Entropy ---------------------------------------------------------------
46
47/** Shannon entropy of `text`, in bits per character. */
48export function entropy(text: string): number {
49  if (text.length === 0) return 0
50  const counts = new Map<string, number>()
51  for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
52  let bits = 0
53  for (const count of counts.values()) {
54    const p = count / text.length
55    bits -= p * Math.log2(p)
56  }
57  return bits
58}
59
60// --- Paths -----------------------------------------------------------------
61
62export type PathKind = 'env' | 'private-key' | 'credentials' | 'terraform-state' | 'package-auth'
63
64export type PathClass = {
65  kind: PathKind
66  label: string
67  /** The file is only sensitive when it holds a token (.npmrc, .pypirc). */
68  requiresToken: boolean
69}
70
71const baseName = (path: string): string => path.split(/[\\/]/).pop()?.toLowerCase() ?? ''
72
73const ENV_SAFE_SUFFIX = /\.(?:example|sample|template|dist|defaults)$/
74
75/** The sensitive-file rules, by path alone. `undefined`: not sensitive. */
76export function classifyPath(path: string): PathClass | undefined {
77  const name = baseName(path)
78  if (name === '') return undefined
79  if (/^\.env(?:\..+)?$/.test(name) || /\.env$/.test(name)) {
80    return ENV_SAFE_SUFFIX.test(name) ? undefined : { kind: 'env', label: 'Environment file', requiresToken: false }
81  }
82  if (/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?$/.test(name)) {
83    return name.endsWith('.pub') ? undefined : { kind: 'private-key', label: 'SSH private key', requiresToken: false }
84  }
85  if (/\.(?:pem|key|p12|pfx|jks|keystore)$/.test(name)) {
86    return { kind: 'private-key', label: 'Key or certificate file', requiresToken: false }
87  }
88  if (name === 'credentials.json' || /^service-account.*\.json$/.test(name)) {
89    return { kind: 'credentials', label: 'Credentials file', requiresToken: false }
90  }
91  if (/\.tfstate(?:\.backup)?$/.test(name)) {
92    return { kind: 'terraform-state', label: 'Terraform state', requiresToken: false }
93  }
94  if (name === '.npmrc' || name === '.pypirc') {
95    return { kind: 'package-auth', label: 'Package registry config', requiresToken: true }
96  }
97  return undefined
98}
99
100/** Firebase's client config files: the Google API key in them identifies the app and is committed by design, so it only warns. */
101const FIREBASE_CLIENT_FILES = new Set(['googleservice-info.plist', 'google-services.json'])
102
103const LOCKFILES = new Set([
104  'package-lock.json',
105  'npm-shrinkwrap.json',
106  'yarn.lock',
107  'pnpm-lock.yaml',
108  'bun.lock',
109  'cargo.lock',
110  'poetry.lock',
111  'pipfile.lock',
112  'composer.lock',
113  'gemfile.lock',
114  'go.sum',
115])
116
117// --- Allowlist and false-positive exclusions -------------------------------
118
119/** Long words and shapes that a real credential never contains by chance. */
120const STRONG_PLACEHOLDER = /example|placeholder|changeme|change[-_ ]?me|dummy|sample|redacted|insert|replace|(?:^|[-_])your[-_]|<[^>]*>|\$\{[^}]*\}|\{\{[^}]*\}\}|^\$[A-Za-z_]|^%\(/i
121/** Short fragments that a random key can contain by chance (`-my_`, `xxxx`): they only count in a low-entropy value. */
122const WEAK_PLACEHOLDER = /(?:^|[-_])my[-_]|todo|fixme|x{4,}|\*{4,}|•{3,}|\.{3,}|0{8,}|#{4,}/i
123/** A random 20+ character key is above this; `your-key-here` and `xxxxxxxx` are well below. */
124const RANDOM_ENTROPY = 4.2
125const WEAK_PASSWORDS = new Set(['password', 'passwd', 'pass', 'pwd', 'secret', 'admin', 'root', 'test', 'user', 'guest', 'postgres', 'mysql', 'redis'])
126
127/**
128 * Placeholders and documentation examples: `your-api-key`, `changeme`,
129 * `<TOKEN>`, `xxxx…`, AWS's `…EXAMPLE` keys and anything that is a reference
130 * to a variable instead of a value. A high-entropy value is never called a
131 * placeholder because of a short fragment: that would let a real key through.
132 */
133export function isPlaceholder(value: string): boolean {
134  if (STRONG_PLACEHOLDER.test(value)) return true
135  if (WEAK_PLACEHOLDER.test(value) && entropy(value) < RANDOM_ENTROPY) return true
136  if (WEAK_PASSWORDS.has(value.toLowerCase())) return true
137  // One repeated character, with or without a known prefix.
138  return /^(.)\1+$/.test(value.replace(/^[A-Za-z]{1,6}[-_]/, ''))
139}
140
141const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
142const GIT_SHA = /^[0-9a-f]{40}$/i
143const INTEGRITY = /^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/
144const DATA_URI = /^data:[a-z]+\/[a-z0-9.+-]+;base64,/i
145const DOTTED_PATH = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/
146const CONSTANT_NAME = /^[A-Z][A-Z0-9_]+$/
147const WORD_LIST = /^[A-Za-z]+(?:[-_][A-Za-z]+)+$/
148
149/** Typical false positives of the generic heuristic: hashes, ids, code, URLs and paths. */
150export function isFalsePositive(value: string): boolean {
151  return (
152    UUID.test(value) ||
153    GIT_SHA.test(value) ||
154    INTEGRITY.test(value) ||
155    DATA_URI.test(value) ||
156    DOTTED_PATH.test(value) || // process.env.API_KEY, os.environ.get
157    CONSTANT_NAME.test(value) || // the name of an environment variable
158    WORD_LIST.test(value) || // some-config-value
159    value.includes('://') ||
160    value.startsWith('/') ||
161    value.startsWith('./') ||
162    value.startsWith('../')
163  )
164}
165
166// --- Scanning --------------------------------------------------------------
167
168/**
169 * Custom rules come from the repository, so they run under a cost bound whatever
170 * they are: over windows of this many characters, overlapping by the difference
171 * with STRIDE, and within a total time budget. A slow pattern can then cost at
172 * most a window's worth per window, and never the hook's 10 seconds.
173 */
174const WINDOW = 600
175const STRIDE = 400
176const CUSTOM_BUDGET_MS = 500
177
178type Budget = { deadline: number; isPartial: boolean }
179
180function* windowedMatches(regex: RegExp, text: string, budget: Budget): Generator<RegExpMatchArray> {
181  const seen = new Set<number>()
182  for (let from = 0; from < text.length; from += STRIDE) {
183    if (performance.now() > budget.deadline) {
184      budget.isPartial = true
185      return
186    }
187    for (const match of text.slice(from, from + WINDOW).matchAll(regex)) {
188      const start = from + (match.index ?? 0)
189      if (seen.has(start)) continue
190      seen.add(start)
191      match.index = start
192      yield match
193    }
194    if (from + WINDOW >= text.length) return
195  }
196}
197
198/** 1-based line of each offset, from a table of line starts built once. */
199function lineIndex(text: string): (offset: number) => number {
200  const starts = [0]
201  for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
202  return (offset) => {
203    let low = 0
204    let high = starts.length - 1
205    while (low < high) {
206      const mid = (low + high + 1) >> 1
207      if ((starts[mid] ?? 0) <= offset) low = mid
208      else high = mid - 1
209    }
210    return low + 1
211  }
212}
213
214const overlaps = (a: Finding, b: Finding): boolean => a.start < b.end && b.start < a.end
215
216/** Scans `text` for secrets. Findings come back in order of position. */
217export function scanText(text: string, options: ScanOptions = {}): ScanResult {
218  if (text.length > MAX_SCAN_CHARS) return { findings: [], isSkipped: true, isPartial: false }
219
220  const name = options.path === undefined ? '' : baseName(options.path)
221  const rules: Rule[] = [...RULES, ...(options.extraRules ?? [])]
222  if (name === '.npmrc') rules.push(...NPMRC_RULES)
223  if (name === '.pypirc') rules.push(...PYPIRC_RULES)
224  // Lockfiles are full of integrity hashes; only precise provider rules apply.
225  const isLockfile = LOCKFILES.has(name)
226
227  const lineOf = lineIndex(text)
228  const found: Finding[] = []
229  const budget: Budget = { deadline: performance.now() + CUSTOM_BUDGET_MS, isPartial: false }
230
231  for (const rule of rules) {
232    if (isLockfile && rule.isGeneric) continue
233    const matches = rule.id.startsWith('custom:') ? windowedMatches(rule.regex, text, budget) : text.matchAll(rule.regex)
234    for (const match of matches) {
235      const groupValue = rule.groups?.map((g) => match[g]).find((v) => v !== undefined)
236      const value = rule.groups === undefined ? match[0] : groupValue
237      if (value === undefined || value === '') continue
238      if (isPlaceholder(value)) continue
239      if (rule.accept !== undefined && !rule.accept(value, match)) continue
240      if (rule.minEntropy !== undefined && entropy(value) < rule.minEntropy) continue
241      if (rule.isGeneric === true && isFalsePositive(value)) continue
242      const start = match.index ?? 0
243      found.push({
244        ruleId: rule.id,
245        label: rule.label,
246        severity: rule.id === 'google-api-key' && FIREBASE_CLIENT_FILES.has(name) ? 'medium' : rule.severity,
247        line: lineOf(start),
248        start,
249        end: start + match[0].length,
250        value,
251        prefix: rule.prefix,
252        isGeneric: rule.isGeneric === true,
253      })
254    }
255  }
256
257  // A precise finding wins over a heuristic one on the same text.
258  const precise = found.filter((f) => !f.isGeneric)
259  const findings = [...precise, ...found.filter((f) => f.isGeneric && !precise.some((p) => overlaps(f, p)))]
260  findings.sort((a, b) => a.start - b.start)
261  return { findings, isSkipped: false, isPartial: budget.isPartial }
262}
263
264/** Write: the whole content and the path. */
265export function scanWrite(path: string, content: string, options: Omit<ScanOptions, 'path'> = {}): ScanResult {
266  return scanText(content, { ...options, path })
267}
268
269/**
270 * Edit: only the new text, so secrets that were already in the file do not
271 * warn. A secret that is also in the replaced text was there before.
272 */
273export function scanEdit(path: string, oldString: string, newString: string, options: Omit<ScanOptions, 'path'> = {}): ScanResult {
274  const result = scanText(newString, { ...options, path })
275  return { ...result, findings: result.findings.filter((f) => !oldString.includes(f.value)) }
276}
277
hooks/diff.ts 86 lines
1// Scans the lines a diff adds. Pure: no `$`, no I/O.
2//
3// `git diff --cached` says what a commit would contain and `git log -p` what a
4// push would publish. Only added lines matter: a secret that is being removed
5// is not being leaked.
6
7import { scanText } from './detect.ts'
8import type { Finding, Rule } from './detect.ts'
9
10export type AddedFile = {
11  path: string
12  /** The added lines, in order. */
13  lines: string[]
14  /** The line of each added line in the new file. */
15  numbers: number[]
16}
17
18export type DiffFinding = Finding & { path: string }
19
20const unquote = (path: string): string => (path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1) : path)
21
22/** The added lines of a unified diff, grouped by file. */
23export function addedLines(diff: string): AddedFile[] {
24  const files = new Map<string, AddedFile>()
25  let current: AddedFile | undefined
26  let line = 0
27  let isInHunk = false
28
29  for (const row of diff.split('\n')) {
30    if (row.startsWith('diff --git ')) {
31      current = undefined
32      isInHunk = false
33      continue
34    }
35    if (!isInHunk && row.startsWith('+++ ')) {
36      const target = unquote(row.slice(4).replace(/\t.*$/, ''))
37      if (target === '/dev/null') {
38        current = undefined
39      } else {
40        const path = target.startsWith('b/') ? target.slice(2) : target
41        current = files.get(path) ?? { path, lines: [], numbers: [] }
42        files.set(path, current)
43      }
44      continue
45    }
46    const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(row)
47    if (hunk !== null) {
48      line = Number(hunk[1])
49      isInHunk = true
50      continue
51    }
52    if (!isInHunk || current === undefined) continue
53    if (row.startsWith('+')) {
54      current.lines.push(row.slice(1))
55      current.numbers.push(line++)
56    } else if (row.startsWith(' ')) {
57      line++
58    }
59  }
60  return [...files.values()]
61}
62
63export type DiffScan = {
64  findings: DiffFinding[]
65  /** A file's added text was over 4 MiB and was not scanned. */
66  isSkipped: boolean
67}
68
69/** Scans each file's added lines; a finding points at the real line in the file. */
70export function scanDiff(diff: string, extraRules: readonly Rule[] = []): DiffScan {
71  const findings: DiffFinding[] = []
72  let isSkipped = false
73  for (const file of addedLines(diff)) {
74    if (file.lines.length === 0) continue
75    const result = scanText(file.lines.join('\n'), { path: file.path, extraRules })
76    if (result.isSkipped) {
77      isSkipped = true
78      continue
79    }
80    for (const finding of result.findings) {
81      findings.push({ ...finding, line: file.numbers[finding.line - 1] ?? finding.line, path: file.path })
82    }
83  }
84  return { findings, isSkipped }
85}
86
hooks/mask.ts 58 lines
1// Masking and fingerprints. Pure: no `$`.
2//
3// This is the only place a Finding's `value` is read. What leaves it is a
4// MaskedFinding: the type, the location, a masked preview and a fingerprint.
5// That is all `$.state`, `$.store`, the interface and the messages to Claude
6// may ever hold.
7
8import type { Finding } from './detect.ts'
9
10export type MaskedFinding = Omit<Finding, 'value' | 'start' | 'end'> & {
11  /** `sk-ant-••••••3fA`, or `••••••` when the value has no safe part to show. */
12  masked: string
13  /** `sha256:` and 16 hex characters of the value's SHA-256. */
14  fingerprint: string
15}
16
17const DOTS = '••••••'
18/** Below this many characters after the prefix, the tail would give too much away. */
19const MIN_TAIL_BODY = 16
20
21/**
22 * Identifying prefix and the last three characters. A value with no known
23 * prefix shows nothing: the type already says what it is.
24 */
25export function mask(value: string, prefix?: string): string {
26  if (prefix === undefined || !value.startsWith(prefix)) return DOTS
27  const body = value.length - prefix.length
28  return body >= MIN_TAIL_BODY ? `${prefix}${DOTS}${value.slice(-3)}` : `${prefix}${DOTS}`
29}
30
31/** `sha256:` plus the first 8 bytes of the digest, in hex. */
32export async function fingerprint(value: string): Promise<string> {
33  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
34  const hex = Array.from(new Uint8Array(digest).slice(0, 8), (byte) => byte.toString(16).padStart(2, '0')).join('')
35  return `sha256:${hex}`
36}
37
38/** The finding without its value: the only form that may be kept or shown. */
39export async function describe(finding: Finding): Promise<MaskedFinding> {
40  const { value, start: _start, end: _end, ...rest } = finding
41  return { ...rest, masked: mask(value, finding.prefix), fingerprint: await fingerprint(value) }
42}
43
44/**
45 * `text` with each finding's value replaced by its masked form. The findings must come
46 * from scanning this same `text`; overlapping ones keep the first replacement.
47 */
48export function redact(text: string, findings: readonly Finding[]): string {
49  let out = text
50  let limit = Number.POSITIVE_INFINITY
51  for (const finding of [...findings].sort((a, b) => b.start - a.start)) {
52    if (finding.end > limit) continue
53    out = out.slice(0, finding.start) + mask(finding.value, finding.prefix) + out.slice(finding.end)
54    limit = finding.start
55  }
56  return out
57}
58
hooks/messages.ts 314 lines
1// What LeakStop says: the hold question and the denial message. Pure: no `$`.
2//
3// Everything here is built from a MaskedFinding, so no complete secret can
4// reach the interface or the model. The most that appears is the rule's
5// identifying prefix and the last three characters.
6
7import { classifyPath } from './detect.ts'
8import type { MaskedFinding } from './mask.ts'
9import { destinationOf, toolLabel } from './outbound.ts'
10
11/** The labels of the options in the dialogs. */
12export const USE_ENV = 'Use environment variable'
13export const ALLOW_ONCE = 'Allow once'
14export const CANCEL = 'Cancel'
15export const SHOW_NAMES = 'Show names only'
16export const ADD_GITIGNORE = 'Add to .gitignore'
17
18const MAX_FREE_TEXT = 200
19
20/**
21 * What the model reads before a denial: who decided, so it neither retries after a cancel nor asks the user
22 * again after a choice that already told it what to do. `answer` is `undefined` when nobody could answer.
23 */
24export function answerNote(answer: string | undefined): string {
25  switch (answer) {
26    case undefined:
27      return 'LeakStop could not ask the user, so the action was denied.'
28    case CANCEL:
29      return 'The user chose Cancel: do not retry this action or work around it. Ask them how they want to proceed.'
30    case USE_ENV:
31      return 'The user chose "Use environment variable": do that now instead of writing the value.'
32    case ADD_GITIGNORE:
33      return 'The user chose "Add to .gitignore": add those files to .gitignore now, then retry.'
34    default: {
35      const text = answer.replace(/\s+/g, ' ').trim()
36      const shown = text.length > MAX_FREE_TEXT ? `${text.slice(0, MAX_FREE_TEXT)}…` : text
37      return `The user picked no option and answered: "${shown}". That is not an approval of the original action; follow what they said, and ask them if it is unclear.`
38    }
39  }
40}
41
42/**
43 * The question as one line, for a surface that does not keep line breaks (the VS Code panel runs them
44 * together into one paragraph): the lines read in order, set apart by dashes.
45 */
46export function flatten(question: string): string {
47  return question
48    .split('\n')
49    .map((line) => line.trim())
50    .filter((line) => line !== '')
51    .join(' — ')
52}
53
54/** The tools whose write is checked. */
55export type WriteTool = 'Write' | 'Edit' | 'NotebookEdit'
56
57const VERB: Record<WriteTool, string> = { Write: 'write', Edit: 'edit', NotebookEdit: 'notebook edit' }
58
59/** The variable a secret of each kind belongs in. */
60const ENV_VARS: Record<string, string> = {
61  'aws-access-key': 'AWS_ACCESS_KEY_ID',
62  'github-token': 'GITHUB_TOKEN',
63  'github-fine-grained-token': 'GITHUB_TOKEN',
64  'gitlab-token': 'GITLAB_TOKEN',
65  'anthropic-key': 'ANTHROPIC_API_KEY',
66  'openai-key': 'OPENAI_API_KEY',
67  'stripe-live-key': 'STRIPE_SECRET_KEY',
68  'stripe-test-key': 'STRIPE_SECRET_KEY',
69  'slack-token': 'SLACK_BOT_TOKEN',
70  'google-api-key': 'GOOGLE_API_KEY',
71  'npm-token': 'NPM_TOKEN',
72  'npmrc-auth-token': 'NPM_TOKEN',
73  'huggingface-token': 'HF_TOKEN',
74  'pypirc-password': 'PYPI_TOKEN',
75  'url-credentials': 'DATABASE_URL',
76  'jwt': 'AUTH_TOKEN',
77}
78
79/** `src/config.ts` for `/work/app/src/config.ts` when the session runs in `/work/app`. */
80export function displayPath(path: string, cwd?: string): string {
81  if (cwd === undefined || cwd === '') return path
82  const root = cwd.endsWith('/') ? cwd : `${cwd}/`
83  return path.startsWith(root) ? path.slice(root.length) : path
84}
85
86const article = (label: string): string => (/^(?:[aeiou]|npm|AWS|SSH)/i.test(label) ? 'an' : 'a')
87
88const typeOf = (finding: MaskedFinding): string => `${article(finding.label)} ${finding.label}`
89
90/** `sk-ant-…`, or nothing when the rule has no public prefix. */
91const hint = (finding: MaskedFinding): string => (finding.prefix === undefined ? '' : ` (${finding.prefix}…)`)
92
93function advice(path: string, findings: readonly MaskedFinding[]): string {
94  if (classifyPath(path)?.kind === 'env') {
95    return 'This file is not ignored by git: add it to .gitignore first, then write the secret there.'
96  }
97  const first = findings[0]
98  if (first?.ruleId === 'private-key') {
99    return 'Keep the key out of the repository: store it outside the project or in a secret manager, and read its path from an environment variable.'
100  }
101  const name = first === undefined ? undefined : ENV_VARS[first.ruleId]
102  if (name === undefined) {
103    return 'Read the value from an environment variable instead, add it to .env (ignored by git) and declare the variable without a value in .env.example.'
104  }
105  return `Read the value from the environment variable ${name} instead (for example process.env.${name}), add it to .env (ignored by git) and declare the variable without a value in .env.example.`
106}
107
108/** The message Claude reads when a write is denied: type, location and alternative, never the value. */
109export function denyMessage(tool: WriteTool, path: string, findings: readonly MaskedFinding[]): string {
110  const shown = findings.slice(0, 5)
111  const what = shown.map((f) => `${path}:${f.line} contains ${typeOf(f)}${hint(f)}`).join('; ')
112  const more = findings.length > shown.length ? ` (and ${findings.length - shown.length} more)` : ''
113  return `LeakStop blocked this ${VERB[tool]}: ${what}${more}. ${advice(path, findings)}`
114}
115
116/** The hold question: plain text, because the dialog supports no colors. */
117export function holdQuestion(tool: WriteTool, path: string, findings: readonly MaskedFinding[]): string {
118  const severity = findings.some((f) => f.severity === 'critical') ? 'CRITICAL' : 'MEDIUM'
119  const shown = findings.slice(0, 3)
120  const lines = [`LeakStop · ${severity}`]
121  for (const finding of shown) {
122    lines.push(`${finding.label} in ${tool} → ${path}:${finding.line}`, `  ${finding.masked}`)
123  }
124  if (findings.length > shown.length) lines.push(`  …and ${findings.length - shown.length} more`)
125  lines.push(
126    classifyPath(path)?.kind === 'env'
127      ? 'This file is not ignored by git and would end up in the repository.'
128      : 'This file is not ignored by git, so the secret would end up in the repository.',
129    '',
130    'How do you want to handle it?',
131  )
132  return lines.join('\n')
133}
134
135/** The question for an edit of LeakStop's own configuration. */
136export function configQuestion(path: string): string {
137  return [
138    'LeakStop · CONFIGURATION',
139    `Claude wants to change ${path}`,
140    "This file can relax LeakStop's rules, so only you should approve it.",
141    '',
142    'Allow this change?',
143  ].join('\n')
144}
145
146export function configDenyMessage(path: string): string {
147  return `LeakStop blocked this change: ${path} controls LeakStop's own rules and can only be changed with the user's approval. Ask the user to edit it.`
148}
149
150/** One transcript line for a finding that warns instead of holding. */
151export function warnLine(path: string, finding: MaskedFinding, wouldHold: boolean): string {
152  const tail = wouldHold ? ' · monitor mode: this would have been held' : ''
153  return `LeakStop · ${finding.severity.toUpperCase()} · ${finding.label} in ${path}:${finding.line}${tail}`
154}
155
156// --- Bash and Read ------------------------------------------------------------
157
158const list = (items: readonly string[], max = 5): string => {
159  const shown = items.slice(0, max).join(', ')
160  return items.length > max ? `${shown} and ${items.length - max} more` : shown
161}
162
163/** What the model reads when a command with a literal secret is denied. */
164export function commandSecretDeny(findings: readonly MaskedFinding[]): string {
165  const what = findings.slice(0, 5).map((f) => `${typeOf(f)}${hint(f)}`).join(', ')
166  const name = findings[0] === undefined ? undefined : ENV_VARS[findings[0].ruleId]
167  const variable = name === undefined ? 'an environment variable' : `an environment variable (for example ${name})`
168  const use = name === undefined ? 'a variable' : `$${name}`
169  return `LeakStop blocked this command: it contains ${what}. Keep the value in ${variable}, defined in .env (ignored by git), and refer to it as ${use} instead of writing it out.`
170}
171
172export function commandSecretQuestion(findings: readonly MaskedFinding[]): string {
173  const severity = findings.some((f) => f.severity === 'critical') ? 'CRITICAL' : 'MEDIUM'
174  const lines = [`LeakStop · ${severity}`]
175  for (const finding of findings.slice(0, 3)) lines.push(`${finding.label} in the Bash command`, `  ${finding.masked}`)
176  if (findings.length > 3) lines.push(`  …and ${findings.length - 3} more`)
177  lines.push('Written out in a command, the secret stays in the session history.', '', 'How do you want to handle it?')
178  return lines.join('\n')
179}
180
181/** Printing sensitive files or the environment: the values would enter the conversation. */
182export function dumpQuestion(subject: string, items: readonly string[]): string {
183  return [
184    'LeakStop · CRITICAL',
185    subject,
186    `  ${list(items)}`,
187    "The values would enter the model's context and the session history.",
188    '',
189    'How do you want to handle it?',
190  ].join('\n')
191}
192
193export function fileReadDeny(files: readonly string[]): string {
194  return `LeakStop blocked this command: it would print ${list(files)} into the conversation, where the values would stay in the model's context and the session history. Do not read the file. To see which variables exist, read .env.example or ask the user. To add a variable without reading the file, append it: echo 'NAME=value' >> .env`
195}
196
197export const ENV_DUMP_DENY =
198  'LeakStop blocked this command: printing the whole environment would put secret values into the conversation. Ask for the specific variable you need, or list names only with: env | cut -d= -f1'
199
200export function secretVarDeny(names: readonly string[]): string {
201  return `LeakStop blocked this command: it would print the value of ${list(names)} into the conversation. Use the variable without printing it (for example by passing it to the program that needs it), or ask the user.`
202}
203
204export function gitAddQuestion(files: readonly string[]): string {
205  return [
206    'LeakStop · CRITICAL',
207    'git add would stage files that hold secrets and are not ignored by git',
208    `  ${list(files)}`,
209    'They would end up in the next commit.',
210    '',
211    'How do you want to handle it?',
212  ].join('\n')
213}
214
215export function gitAddDeny(files: readonly string[]): string {
216  return `LeakStop blocked git add: ${list(files)} hold secrets and are not ignored by git. Add them to .gitignore (and run git rm --cached on any that are already tracked), then retry. Stage specific files instead of -A or . while sensitive files are not ignored.`
217}
218
219/** A commit or push that would publish a secret. The fingerprint lets the user allow that one finding. */
220export function gitBlockMessage(operation: 'commit' | 'push', findings: readonly (MaskedFinding & { path: string })[]): string {
221  const what = findings.slice(0, 5).map((f) => `${typeOf(f)}${hint(f)} at ${f.path}:${f.line}`).join('; ')
222  const more = findings.length > 5 ? ` (and ${findings.length - 5} more)` : ''
223  const subject = operation === 'commit' ? 'the staged changes add' : 'the commits to be pushed add'
224  const fix =
225    operation === 'commit'
226      ? 'Unstage the file (git restore --staged <file>), read the value from an environment variable instead and commit again.'
227      : 'Remove the secret from those commits before pushing, read it from an environment variable instead, and rotate it if it was ever shared.'
228  const allow = findings.slice(0, 3).map((f) => f.fingerprint).join(' ')
229  return `LeakStop blocked this git ${operation}: ${subject} ${what}${more}. ${fix} If the user wants it anyway, they can run /leakstop allow ${allow}`
230}
231
232export function readQuestion(path: string): string {
233  return ['LeakStop · CRITICAL', 'Read of a sensitive file', `  ${path}`, "Its contents would enter the model's context and the session history.", '', 'How do you want to handle it?'].join('\n')
234}
235
236export function readDeny(path: string, isStrict: boolean): string {
237  const never = isStrict ? ' Strict mode never allows reading sensitive files.' : ''
238  return `LeakStop blocked reading ${path}: it holds secrets that would enter the conversation.${never} Do not read it; read .env.example for variable names or ask the user. To add a variable without reading the file, append it: echo 'NAME=value' >> .env`
239}
240
241/** The transcript line for something that warns instead of holding. */
242export function noticeLine(what: string, isMonitor: boolean): string {
243  return `LeakStop · ${what}${isMonitor ? ' · monitor mode: this would have been held' : ''}`
244}
245
246/** A recursive search that would print lines of sensitive files nobody named. */
247export function searchQuestion(files: readonly string[]): string {
248  return dumpQuestion('This search would print lines from files that hold secrets', files)
249}
250
251export function searchDeny(files: readonly string[]): string {
252  return `LeakStop blocked this search: it would print lines from ${list(files)}, which hold secrets, into the conversation. Search specific folders such as src/ instead, or exclude those files (for example grep -r --exclude='.env*' …, or rg -g '!.env*'). To list only the files that match, use grep -rl or rg -l.`
253}
254
255// --- Outbound tools ------------------------------------------------------------
256
257type Located = MaskedFinding & { path: string }
258
259/** What the model reads when a call that sends something away is denied: where the secret is and what to do instead. */
260export function outboundDeny(tool: string, findings: readonly Located[]): string {
261  const what = findings.slice(0, 5).map((f) => `${f.path} contains ${typeOf(f)}${hint(f)}`).join('; ')
262  const more = findings.length > 5 ? ` (and ${findings.length - 5} more)` : ''
263  return `LeakStop blocked this ${toolLabel(tool)} call: ${what}${more}. It would be sent ${destinationOf(tool)}, and a credential that leaves the session cannot be taken back. Leave the value out: describe what is needed, or name the environment variable that holds it, and send that instead.`
264}
265
266export function outboundQuestion(tool: string, findings: readonly Located[]): string {
267  const severity = findings.some((f) => f.severity === 'critical') ? 'CRITICAL' : 'MEDIUM'
268  const lines = [`LeakStop · ${severity}`]
269  // The tool is already named: "in Agent → prompt", not "in Agent → Agent › prompt".
270  const own = `${toolLabel(tool)} › `
271  for (const finding of findings.slice(0, 3)) lines.push(`${finding.label} in ${toolLabel(tool)} → ${finding.path.startsWith(own) ? finding.path.slice(own.length) : finding.path}`, `  ${finding.masked}`)
272  if (findings.length > 3) lines.push(`  …and ${findings.length - 3} more`)
273  lines.push(`This call would send it ${destinationOf(tool)}.`, '', 'How do you want to handle it?')
274  return lines.join('\n')
275}
276
277/** A file that holds secrets named in a call that sends it away. */
278export function outboundFileQuestion(tool: string, files: readonly string[]): string {
279  return ['LeakStop · CRITICAL', `${toolLabel(tool)} would send files that hold secrets`, `  ${list(files)}`, `Their contents would go ${destinationOf(tool)}.`, '', 'How do you want to handle it?'].join('\n')
280}
281
282export function outboundFileDeny(tool: string, files: readonly string[]): string {
283  return `LeakStop blocked this ${toolLabel(tool)} call: ${list(files)} hold secrets and would be sent ${destinationOf(tool)}. Do not send them. Send a copy without the secrets (for example .env.example with the values removed), or ask the user.`
284}
285
286// --- Tool output -----------------------------------------------------------------
287
288/** What the model reads after an output in which LeakStop masked secrets. */
289export function maskedContext(tool: string, findings: readonly MaskedFinding[]): string {
290  const what = findings.slice(0, 5).map(typeOf).join(', ')
291  const more = findings.length > 5 ? ` (and ${findings.length - 5} more)` : ''
292  return `LeakStop masked ${what}${more} in the output of this ${tool} call: the value never reached you and is not in the transcript. Do not try to print or read it another way; if the task needs it, use it from an environment variable or ask the user.`
293}
294
295/** The line above the prompt when an output carried a secret. */
296export function maskedLine(tool: string, finding: MaskedFinding, isMonitor: boolean): string {
297  return `LeakStop · ${finding.severity.toUpperCase()} · ${finding.label} in the output of ${tool}${isMonitor ? ' · monitor mode: this would have been masked' : ' · masked'}`
298}
299
300/** Instead of an output LeakStop could not check after the tool ran. */
301export const OUTPUT_FAILURE = 'LeakStop could not check the output of this call, so it was withheld. The call did run; do not run it again just to see its output.'
302
303/** What the model reads after a message in which LeakStop masked a pasted secret. */
304export function promptMaskedContext(findings: readonly MaskedFinding[]): string {
305  const what = findings.slice(0, 5).map(typeOf).join(', ')
306  return `LeakStop masked ${what} that the user pasted into this message: the value never reached you. If the task needs it, ask the user to put it in an environment variable or a git-ignored file instead of the chat.`
307}
308
309/** Denial of a Write that would put the masked form of a secret over the real value on disk. */
310export function maskedOverwriteDeny(path: string, findings: readonly MaskedFinding[]): string {
311  const what = findings.slice(0, 5).map((f) => `${typeOf(f)} (${f.masked})`).join(', ')
312  return `LeakStop blocked this write: ${path} holds ${what}, and what you would write has only the masked form LeakStop showed you, so the real value would be lost. Change the file with Edit around that line instead, without including the masked value in old_string or new_string.`
313}
314
hooks/outbound.ts 150 lines
1// What a tool call sends out of the session: to the web, to another agent or
2// session, into a published page or to an MCP server. Pure: no `$`, no I/O.
3//
4// Everything a call carries as text is collected (the model chooses the field
5// names of an MCP tool, so there is no list to keep), and the fields that name
6// local files whose contents travel with the call are listed apart so the hook
7// can read them.
8
9/** The built-in tools that send what they are given to somewhere else. MCP tools are matched by their `mcp__` prefix. */
10export const OUTBOUND_TOOLS = ['WebFetch', 'WebSearch', 'Agent', 'SendMessage', 'SendFile', 'Artifact', 'ArtifactData', 'ArtifactComments', 'PushNotification', 'SendFeedback', 'RemoteTrigger'] as const
11
12/** Keys the engine puts beside a tool's own arguments. */
13const RESERVED = new Set(['tool', 'tool_use_id', 'agentId'])
14
15const MAX_PARTS = 200
16/** Text past MAX_PARTS is scanned as one block, up to this many characters. */
17const MAX_OVERFLOW = 1024 * 1024
18const MAX_DEPTH = 6
19/** Files whose paths are checked; only the first MAX_READ of them are opened. */
20const MAX_FILES = 200
21export const MAX_READ = 20
22/** Keys shorter than this cannot be a provider token by themselves. */
23const MIN_KEY = 16
24
25/** A piece of text the call carries, and the argument it came from (`prompt`, `batch[0].payload`). */
26export type Part = { field: string; text: string }
27
28export type Outbound = {
29  texts: Part[]
30  /** Local files whose contents the call sends, as the paths it names. */
31  files: string[]
32  /** More text or more files than are looked at; the rest was not read. */
33  isTruncated: boolean
34}
35
36const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
37
38/** Arguments that name files the tool reads and sends. */
39function filesNamed(input: Record<string, unknown>): string[] {
40  const files: string[] = []
41  const add = (value: unknown): void => {
42    if (typeof value === 'string' && value !== '') files.push(value)
43  }
44  const addAll = (value: unknown): void => {
45    if (Array.isArray(value)) value.forEach(add)
46  }
47  switch (input.tool) {
48    case 'SendFile':
49      addAll(input.files)
50      break
51    case 'Artifact': {
52      add(input.file_path)
53      addAll(input.file_paths)
54      // `files` is a list of { path } or a map from published path to a source path or { from }.
55      if (Array.isArray(input.files)) for (const item of input.files) add(isRecord(item) ? item.path : undefined)
56      else if (isRecord(input.files)) for (const source of Object.values(input.files)) add(isRecord(source) ? source.from : source)
57      break
58    }
59    case 'ArtifactData':
60      add(input.file_path)
61      if (Array.isArray(input.writes)) for (const write of input.writes) add(isRecord(write) ? write.file_path : undefined)
62      break
63  }
64  return [...new Set(files)]
65}
66
67/** The text of a call and the files it sends. Nothing is dropped: past the limits the rest is folded into one block. */
68export function collect(input: Record<string, unknown>): Outbound {
69  const texts: Part[] = []
70  let overflow = ''
71  let isTruncated = false
72  const add = (field: string, text: string): void => {
73    if (texts.length < MAX_PARTS) {
74      texts.push({ field, text })
75    } else if (overflow.length + text.length + 1 <= MAX_OVERFLOW) {
76      overflow += overflow === '' ? text : `\n${text}`
77    } else {
78      isTruncated = true
79    }
80  }
81  const walk = (value: unknown, field: string, depth: number): void => {
82    if (typeof value === 'string') {
83      if (value !== '') add(field, value)
84    } else if (value === null || typeof value !== 'object') {
85      return
86    } else if (depth >= MAX_DEPTH) {
87      // Too deep to follow: its text is scanned as it is serialized.
88      try {
89        add(field, JSON.stringify(value))
90      } catch {
91        isTruncated = true
92      }
93    } else if (Array.isArray(value)) {
94      value.forEach((item, index) => walk(item, `${field}[${index}]`, depth + 1))
95    } else {
96      // A key can hold a secret too (a map keyed by token), so the long ones are scanned, as one block.
97      const keys = Object.keys(value).filter((key) => key.length >= MIN_KEY)
98      if (keys.length > 0) add(`${field} (names)`, keys.join('\n'))
99      for (const [key, item] of Object.entries(value)) walk(item, `${field}.${key}`, depth + 1)
100    }
101  }
102  for (const [key, value] of Object.entries(input)) if (!RESERVED.has(key)) walk(value, key, 0)
103  if (overflow !== '') texts.push({ field: '(further arguments)', text: overflow })
104
105  const named = filesNamed(input)
106  return { texts, files: named.slice(0, MAX_FILES), isTruncated: isTruncated || named.length > MAX_FILES }
107}
108
109/** Files that cannot hold a readable secret: reading them as text only costs time. */
110export const isLikelyBinary = (path: string): boolean => /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|tiff?|pdf|woff2?|ttf|otf|eot|mp[34]|m4a|mov|webm|wav|ogg|zip|gz|tgz|bz2|xz|7z|rar|wasm|bin|exe|dylib|so|class|jar|sqlite3?)$/i.test(path)
111
112const MCP_NAME = /^mcp__(.+?)__(.+)$/
113
114/** `mcp__srv__tool` as `MCP srv/tool`; a built-in tool is its own name. */
115export function toolLabel(tool: string): string {
116  const match = MCP_NAME.exec(tool)
117  return match === null ? tool : `MCP ${match[1]}/${match[2]}`
118}
119
120/** True for a tool whose arguments LeakStop reads as outbound. */
121export const isOutbound = (tool: string): boolean => tool.startsWith('mcp__') || (OUTBOUND_TOOLS as readonly string[]).includes(tool)
122
123/** Where what the call carries ends up, in words. */
124export function destinationOf(tool: string): string {
125  switch (tool) {
126    case 'WebFetch':
127      return 'to the web server it fetches, which can log it'
128    case 'WebSearch':
129      return 'to a search engine'
130    case 'Agent':
131      return "into another agent's context"
132    case 'SendMessage':
133      return 'to another agent or session'
134    case 'SendFile':
135      return 'to another session'
136    case 'Artifact':
137    case 'ArtifactData':
138    case 'ArtifactComments':
139      return 'into a page or database on claude.ai that other people may open'
140    case 'PushNotification':
141      return "to the user's phone through a push service"
142    case 'SendFeedback':
143      return 'to Anthropic'
144    case 'RemoteTrigger':
145      return 'to a remote trigger'
146    default:
147      return MCP_NAME.test(tool) ? `to the MCP server ${MCP_NAME.exec(tool)?.[1] ?? ''}` : 'outside the session'
148  }
149}
150
hooks/policy.ts 60 lines
1// Policy: severity × destination × mode → action. Pure: no `$`.
2
3import type { Severity } from './rules.ts'
4
5export type Mode = 'monitor' | 'standard' | 'strict'
6
7/** Pass: let it through. Warn: let it through and tell the user. Hold: ask. Block: deny without asking. */
8export type Action = 'pass' | 'warn' | 'hold' | 'block'
9
10/** Where the secret, or the sensitive thing, is about to go. */
11export type Destination =
12  | 'file' // Write/Edit to a path git does not ignore
13  | 'ignored-file' // Write/Edit to a path git ignores, like a .env
14  | 'command' // a literal secret inside a Bash command
15  | 'outbound' // a secret in what a tool sends away: the web, another agent, a published page, an MCP server
16  | 'sensitive-dump' // cat/head/less of a sensitive file, printenv, env
17  | 'git-add' // git add -A or . with unignored sensitive files
18  | 'git-commit' // secrets in what is staged
19  | 'git-push' // secrets in the commits about to be pushed
20  | 'read' // the Read tool on a sensitive file
21  | 'config-edit' // Claude editing .leakstop.json
22  | 'prompt' // a secret pasted by the user
23
24const ORDER: readonly Action[] = ['pass', 'warn', 'hold', 'block']
25
26/** The stronger of two actions. */
27export function maxAction(a: Action, b: Action): Action {
28  return ORDER.indexOf(a) >= ORDER.indexOf(b) ? a : b
29}
30
31/** The action for one finding, or one sensitive operation (pass `'critical'` for those). */
32export function decide(destination: Destination, severity: Severity, mode: Mode): Action {
33  if (destination === 'ignored-file') return 'pass'
34  if (mode === 'monitor') return 'warn'
35  if (destination === 'prompt') return 'warn'
36
37  switch (destination) {
38    case 'sensitive-dump':
39    case 'git-add':
40    case 'config-edit':
41      return 'hold'
42    case 'read':
43      return mode === 'strict' ? 'block' : 'hold'
44    case 'git-commit':
45    case 'git-push':
46      if (severity === 'critical') return 'block'
47      return mode === 'strict' ? 'block' : 'warn'
48    case 'file':
49    case 'command':
50    case 'outbound':
51      if (severity === 'critical') return 'hold'
52      return mode === 'strict' ? 'hold' : 'warn'
53  }
54}
55
56/** The strongest action over several findings; `pass` when there are none. */
57export function decideAll(destination: Destination, severities: readonly Severity[], mode: Mode): Action {
58  return severities.reduce<Action>((acc, severity) => maxAction(acc, decide(destination, severity, mode)), 'pass')
59}
60
hooks/ui.ts 183 lines
1// What the banner, the history panel and the `/leakstop` command say. Pure: no `$`.
2//
3// Strings are fitted to the width the surface gives (`bodyColumns`), so nothing
4// here depends on the terminal's own size.
5
6import type { StoredFinding } from '../types'
7import { toolLabel } from './outbound.ts'
8
9/** `…` when a line is cut. */
10export function fit(text: string, width: number): string {
11  if (width <= 0) return ''
12  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
13}
14
15/** `HH:MM` in the user's time zone. */
16export function formatTime(at: number): string {
17  const date = new Date(at)
18  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
19}
20
21const SEVERITY = { critical: 'CRITICAL', medium: 'MEDIUM' } as const
22
23/** Where it happened: a file and line, or the command. */
24export function where(finding: StoredFinding): string {
25  if (finding.path === '') return finding.tool === 'Bash' ? 'Bash command' : `${toolLabel(finding.tool)} call`
26  return finding.line > 0 ? `${finding.path}:${finding.line}` : finding.path
27}
28
29/** What happened to it, in words. */
30export function outcome(finding: StoredFinding): string {
31  switch (finding.decision) {
32    case 'allowed':
33      return 'allowed'
34    case 'denied':
35      return 'denied'
36    case 'warned':
37      return finding.severity === 'critical' ? 'logged, not enforced' : 'warned'
38    case 'passed':
39      return 'passed (git ignores this file)'
40    case 'masked':
41      return finding.tool === 'prompt' ? 'masked before it was sent' : 'masked in the output'
42  }
43}
44
45/**
46 * The one line above the prompt. `width` is the room the band has: the first
47 * row is two cells shorter, because the terminal draws the pane's closing mark over it.
48 */
49export function bannerLine(banner: readonly StoredFinding[], paused: boolean, width: number): string {
50  const room = Math.max(0, width - 2)
51  if (paused) return fit('△ LeakStop · PAUSED · nothing is being checked · /leakstop resume', room)
52  const latest = banner[banner.length - 1]
53  if (latest === undefined) return ''
54  const more = banner.length > 1 ? ` (+${banner.length - 1} more)` : ''
55  const head = `△ LeakStop · ${SEVERITY[latest.severity]} · ${latest.label} in ${where(latest)}${more}`
56  const tail = ` · ${latest.decision === 'masked' ? 'masked' : latest.severity === 'critical' ? 'logged' : 'warned'} · /leakstop`
57  return room < tail.length + 12 ? fit(`${head}${tail}`, room) : `${fit(head, room - tail.length)}${tail}`
58}
59
60export type HistoryRow = { head: string; detail: string }
61
62/** The history, newest first, numbered as `/leakstop allow <number>` counts them. */
63export function historyRows(findings: readonly StoredFinding[], width: number): HistoryRow[] {
64  return findings
65    .map((finding, index): HistoryRow => ({
66      head: fit(`#${index + 1} ${formatTime(finding.at)} ${SEVERITY[finding.severity].padEnd(8)} ${finding.label} · ${where(finding)}`, width),
67      detail: fit(`   → ${outcome(finding)}`, width),
68    }))
69    .reverse()
70}
71
72const MAX_TEXT_ROWS = 15
73
74/** The same history as plain text, for where no panel can be drawn. */
75export function summaryText(findings: readonly StoredFinding[], paused: boolean, warnings: readonly string[] = []): string {
76  const state = paused ? ' · PAUSED (nothing is being checked)' : ''
77  const notes = warnings.slice(0, 5).map((warning) => `.leakstop.json: ${warning}`)
78  if (findings.length === 0) return [`LeakStop · no findings this session${state}`, ...notes].join('\n')
79  const rows = historyRows(findings, 200).slice(0, MAX_TEXT_ROWS)
80  const lines = rows.map((row) => `${row.head} ${row.detail.trim()}`)
81  const hidden = findings.length - rows.length
82  return [
83    `LeakStop · ${findings.length} finding${findings.length === 1 ? '' : 's'} this session${state}`,
84    ...lines,
85    ...(hidden > 0 ? [`…and ${hidden} older`] : []),
86    ...notes,
87    'Allow one for good with /leakstop allow <number or sha256:…>',
88  ].join('\n')
89}
90
91/** Where a fingerprint was allowed. */
92export type AllowSource = 'session' | 'forever' | 'project'
93
94const SOURCE_TEXT: Record<AllowSource, string> = { session: 'this session', forever: 'for good', project: '.leakstop.json' }
95
96export type Allowed = { fingerprint: string; sources: AllowSource[] }
97
98/** Every fingerprint that is allowed, with where each came from, in a stable order. */
99export function mergeAllowed(session: readonly string[], forever: readonly string[], project: readonly string[]): Allowed[] {
100  const merged = new Map<string, Set<AllowSource>>()
101  const add = (source: AllowSource, fingerprints: readonly string[]): void => {
102    for (const fingerprint of fingerprints) merged.set(fingerprint, (merged.get(fingerprint) ?? new Set()).add(source))
103  }
104  add('forever', forever)
105  add('session', session)
106  add('project', project)
107  return [...merged].map(([fingerprint, sources]) => ({ fingerprint, sources: [...sources] }))
108}
109
110const MAX_ALLOWED_ROWS = 25
111
112/** What is allowed, with what the history knows about each finding (a type and a place, never a value). */
113export function allowedText(allowed: readonly Allowed[], findings: readonly StoredFinding[]): string {
114  if (allowed.length === 0) return 'LeakStop · nothing is allowed: every finding is checked'
115  const known = new Map<string, StoredFinding>()
116  for (const finding of findings) known.set(finding.fingerprint, finding)
117  const rows = allowed.slice(0, MAX_ALLOWED_ROWS).map(({ fingerprint, sources }) => {
118    const finding = known.get(fingerprint)
119    const what = finding === undefined ? '' : ` · ${finding.label} · ${where(finding)}`
120    return `  ${fingerprint} · ${sources.map((source) => SOURCE_TEXT[source]).join(' + ')}${what}`
121  })
122  const hidden = allowed.length - rows.length
123  return [
124    `LeakStop · ${allowed.length} allowed`,
125    ...rows,
126    ...(hidden > 0 ? [`…and ${hidden} more`] : []),
127    'Stop allowing one with /leakstop forget <fingerprint>, or everything of yours with /leakstop forget all.',
128    ...(allowed.some((a) => a.sources.includes('project')) ? ['Fingerprints from .leakstop.json are removed by editing that file.'] : []),
129  ].join('\n')
130}
131
132export type CommandArgs =
133  | { kind: 'open' }
134  | { kind: 'pause' }
135  | { kind: 'resume' }
136  | { kind: 'allow'; ids: string[] }
137  | { kind: 'allowed' }
138  | { kind: 'forget'; ids: string[] }
139  | { kind: 'reload' }
140  | { kind: 'usage' }
141
142export function parseArgs(args: string): CommandArgs {
143  const words = args.trim().split(/\s+/).filter((word) => word !== '')
144  const [first, ...rest] = words
145  if (first === undefined) return { kind: 'open' }
146  if (first === 'pause' && rest.length === 0) return { kind: 'pause' }
147  if (first === 'resume' && rest.length === 0) return { kind: 'resume' }
148  if (first === 'allow' && rest.length > 0) return { kind: 'allow', ids: rest }
149  if ((first === 'allowed' || first === 'list') && rest.length === 0) return { kind: 'allowed' }
150  if (first === 'forget' && rest.length > 0) return { kind: 'forget', ids: rest }
151  if (first === 'reload' && rest.length === 0) return { kind: 'reload' }
152  return { kind: 'usage' }
153}
154
155export const USAGE = [
156  'Usage:',
157  '  /leakstop                 show this session’s findings',
158  '  /leakstop pause           stop checking until you resume',
159  '  /leakstop resume          start checking again',
160  '  /leakstop allow <id>...   allow findings for good: a number from the history or a sha256:… fingerprint',
161  '  /leakstop allowed         list what is allowed: for this session, for good, and by .leakstop.json',
162  '  /leakstop forget <id>...  stop allowing findings: a history number or a sha256:… fingerprint',
163  '  /leakstop forget all      stop allowing everything you allowed (what .leakstop.json allows stays)',
164  '  /leakstop reload          read .leakstop.json again',
165].join('\n')
166
167const FINGERPRINT = /^(?:sha256:)?([0-9a-f]{16})$/
168
169/** Turns what the user typed into fingerprints; `unknown` lists what matched nothing. */
170export function resolveIds(ids: readonly string[], findings: readonly StoredFinding[]): { fingerprints: string[]; unknown: string[] } {
171  const fingerprints: string[] = []
172  const unknown: string[] = []
173  for (const id of ids) {
174    const number = /^#?(\d+)$/.exec(id)
175    const hex = FINGERPRINT.exec(id.toLowerCase())
176    const byNumber = number === null ? undefined : findings[Number(number[1]) - 1]
177    if (byNumber !== undefined) fingerprints.push(byNumber.fingerprint)
178    else if (hex !== null) fingerprints.push(`sha256:${hex[1]}`)
179    else unknown.push(id)
180  }
181  return { fingerprints: [...new Set(fingerprints)], unknown }
182}
183
hooks/rules.ts 189 lines
1// The pattern catalog: data only, no `$` and no side effects.
2//
3// Every regex here is linear: quantifiers are bounded or run over a character
4// class that cannot also match the next token, so a hostile input cannot make
5// a scan blow up. A hook that runs out of time is skipped by Claude Code and
6// the call would go on, so a slow regex is a security bug.
7
8export type Severity = 'critical' | 'medium'
9
10export type Rule = {
11  id: string
12  /** What the user reads: "Anthropic API key". */
13  label: string
14  severity: Severity
15  /** Global regex. */
16  regex: RegExp
17  /** The capture groups that can hold the secret; the first one that matched wins. Absent: the whole match. */
18  groups?: readonly number[]
19  /** Fixed identifying prefix, the only part of the value masking may show. */
20  prefix?: string
21  /** Shannon entropy (bits per character) the value must reach. */
22  minEntropy?: number
23  /** Heuristic rule: false-positive exclusions apply and a provider finding on the same text wins. */
24  isGeneric?: boolean
25  /** A last, rule-specific check on the value. */
26  accept?: (value: string, match: RegExpMatchArray) => boolean
27}
28
29const base64Length = (text: string): number => text.replace(/[^A-Za-z0-9+/=]/g, '').length
30
31/** A private key header with a real body; a header alone is documentation or a regex. */
32const hasKeyBody = (value: string): boolean =>
33  base64Length(value.replace(/-----(?:BEGIN|END)[^-]*-----/g, '')) >= 40
34
35export const RULES: readonly Rule[] = [
36  {
37    id: 'private-key',
38    label: 'Private key',
39    severity: 'critical',
40    regex: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]{0,16384}?(?:-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g,
41    accept: hasKeyBody,
42  },
43  {
44    id: 'aws-access-key',
45    label: 'AWS access key ID',
46    severity: 'critical',
47    regex: /\b(?:AKIA|ASIA)[A-Z2-7]{16}\b/g,
48    prefix: 'AKIA',
49  },
50  {
51    id: 'github-token',
52    label: 'GitHub token',
53    severity: 'critical',
54    regex: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g,
55    prefix: 'ghp_',
56  },
57  {
58    id: 'github-fine-grained-token',
59    label: 'GitHub fine-grained token',
60    severity: 'critical',
61    regex: /\bgithub_pat_[A-Za-z0-9_]{36,255}\b/g,
62    prefix: 'github_pat_',
63  },
64  {
65    id: 'gitlab-token',
66    label: 'GitLab token',
67    severity: 'critical',
68    regex: /\bglpat-[A-Za-z0-9_-]{20,}/g,
69    prefix: 'glpat-',
70  },
71  {
72    id: 'anthropic-key',
73    label: 'Anthropic API key',
74    severity: 'critical',
75    regex: /\bsk-ant-[A-Za-z0-9_-]{20,}/g,
76    prefix: 'sk-ant-',
77  },
78  {
79    id: 'openai-key',
80    label: 'OpenAI API key',
81    severity: 'critical',
82    regex: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g,
83    prefix: 'sk-proj-',
84  },
85  {
86    id: 'stripe-live-key',
87    label: 'Stripe live key',
88    severity: 'critical',
89    regex: /\b[sr]k_live_[A-Za-z0-9]{16,}/g,
90    prefix: 'sk_live_',
91  },
92  {
93    id: 'stripe-test-key',
94    label: 'Stripe test key',
95    severity: 'medium',
96    regex: /\b[sr]k_test_[A-Za-z0-9]{16,}/g,
97    prefix: 'sk_test_',
98  },
99  {
100    id: 'slack-token',
101    label: 'Slack token',
102    severity: 'critical',
103    regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
104    prefix: 'xoxb-',
105  },
106  {
107    id: 'google-api-key',
108    label: 'Google API key',
109    severity: 'critical',
110    regex: /\bAIza[0-9A-Za-z_-]{35}/g,
111    prefix: 'AIza',
112  },
113  {
114    id: 'npm-token',
115    label: 'npm token',
116    severity: 'critical',
117    regex: /\bnpm_[A-Za-z0-9]{36}\b/g,
118    prefix: 'npm_',
119  },
120  {
121    id: 'huggingface-token',
122    label: 'Hugging Face token',
123    severity: 'critical',
124    regex: /\bhf_[A-Za-z0-9]{34,}/g,
125    prefix: 'hf_',
126  },
127  {
128    // scheme://user:password@host. The secret is the password (group 2).
129    id: 'url-credentials',
130    label: 'Credentials in a URL',
131    severity: 'critical',
132    regex: /\b[a-z][a-z0-9+.-]{1,20}:\/\/([^\s:@/'"<>]{0,100}):([^\s@/'"<>]{3,200})@[^\s'"<>/]{1,255}/gi,
133    groups: [2],
134    // `REDACTED://postgres:postgres@localhost` is a local default, not a secret.
135    accept: (_value, match) => (match[1] ?? '').toLowerCase() !== (match[2] ?? '').toLowerCase(),
136  },
137  {
138    id: 'jwt',
139    label: 'JSON Web Token',
140    severity: 'medium',
141    regex: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
142    prefix: 'eyJ',
143  },
144  {
145    // curl -H "Authorization: Bearer <literal>". A variable (`$TOKEN`) never matches.
146    id: 'authorization-header',
147    label: 'Authorization header credential',
148    severity: 'critical',
149    regex: /\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token)\s+([A-Za-z0-9._~+/=-]{20,})/gi,
150    groups: [1],
151    minEntropy: 3,
152  },
153  {
154    // A suspicious key name, a separator and a high-entropy value. The value is
155    // quoted (groups 1 and 2) or bare (group 3, which must also contain a digit
156    // so identifiers such as `getApiKeyFromConfig` do not count).
157    id: 'generic-assignment',
158    label: 'Hard-coded credential',
159    severity: 'medium',
160    regex:
161      /(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|token)[A-Za-z0-9_-]{0,20}["']?\s*[:=]>?\s*(?:"([^"\s]{16,200})"|'([^'\s]{16,200})'|([A-Za-z0-9_+/=.-]{20,200}))/gi,
162    groups: [1, 2, 3],
163    minEntropy: 3.5,
164    isGeneric: true,
165    accept: (value, match) => match[3] === undefined || /\d/.test(value),
166  },
167]
168
169/** Rules that only make sense for one kind of file. */
170export const NPMRC_RULES: readonly Rule[] = [
171  {
172    id: 'npmrc-auth-token',
173    label: 'npm registry auth token',
174    severity: 'critical',
175    regex: /_authToken\s*=\s*([^\s$]{8,})/g,
176    groups: [1],
177  },
178]
179
180export const PYPIRC_RULES: readonly Rule[] = [
181  {
182    id: 'pypirc-password',
183    label: 'PyPI password or token',
184    severity: 'critical',
185    regex: /^[ \t]*password[ \t]*[:=][ \t]*([^\s$]{6,})/gim,
186    groups: [1],
187  },
188]
189
types/index.d.ts 61 lines
1// The contract of LeakStop's `$.state` values. Values only ever hold what is
2// safe to keep: types, locations and fingerprints, never a secret.
3
4export type Decision = 'allowed' | 'denied' | 'warned' | 'passed' | 'masked'
5
6export type StoredFinding = {
7  /** `sha256:` and 16 hex characters. */
8  fingerprint: string
9  ruleId: string
10  /** What the user reads, "Anthropic API key". */
11  label: string
12  severity: 'critical' | 'medium'
13  path: string
14  line: number
15  tool: string
16  decision: Decision
17  /** Milliseconds since the epoch. */
18  at: number
19}
20
21/** A custom rule from `.leakstop.json`, validated and ready to compile. */
22export type StoredRule = {
23  /** `custom:` and the id the user gave it. */
24  id: string
25  label: string
26  severity: 'critical' | 'medium'
27  /** The regular expression source. */
28  source: string
29  prefix?: string
30  groups?: number[]
31  minEntropy?: number
32}
33
34/** `.leakstop.json` after validation. */
35export type StoredConfig = {
36  /** Globs where medium findings do not warn. Critical findings are still held. */
37  ignorePaths: string[]
38  /** `sha256:` fingerprints the project allows for the whole team. */
39  allowFingerprints: string[]
40  customRules: StoredRule[]
41  /** What was wrong with the file, in words; empty when it is fine. */
42  warnings: string[]
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    leakstop: {
48      /** The session's findings, newest last, bounded. */
49      findings: StoredFinding[]
50      /** Fingerprints the user allowed for this session only. */
51      allowOnce: string[]
52      /** Set by `/leakstop pause`. */
53      paused: boolean
54      /** Warnings the user has not seen yet; cleared by the next prompt or by `/leakstop`. */
55      banner: StoredFinding[]
56      /** The project's `.leakstop.json`, read at session start. */
57      config: StoredConfig
58    }
59  }
60}
61