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

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.
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.
Authorization headers; and password = "…"-style assignments.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.git commit or git push that would publish a secret is blocked.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.
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.
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.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.
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.
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").
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.
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.
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.
hooks/leakstop.tsx 1106 lines1// 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
1106hooks/commands.ts 655 lines1// 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}
655hooks/config.ts 248 lines1// `.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}
248hooks/detect.ts 277 lines1// 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}
277hooks/diff.ts 86 lines1// 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}
86hooks/mask.ts 58 lines1// 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}
58hooks/messages.ts 314 lines1// 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}
314hooks/outbound.ts 150 lines1// 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}
150hooks/policy.ts 60 lines1// 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}
60hooks/ui.ts 183 lines1// 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}
183hooks/rules.ts 189 lines1// 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]
189types/index.d.ts 61 lines1// 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