Keeps secrets out of Claude's context: gitleaks scans every tool output, prompt and attachment before the model reads it, and you decide what the model sees.

secret-guard is a Claude Code mod (a plugin of function hooks) that stops API keys, tokens, passwords and private keys from reaching the model. Every tool output, prompt and attachment is scanned with gitleaks before the model reads it. When a secret turns up, the agent pauses and you decide: cut the secret out, hide the whole output, or let it through.
Bash: cat .env: secrets found (github-pat ghp_…[40]). What should the model see?
1. Cut the secrets
2. Hide the whole output
3. Let the model see it
4. Not a secret, allow
The model then reads GITHUB_TOKEN=[SECRET:github-pat#1] instead of the token, and never learns the value.
A coding agent reads whatever is in front of it: cat .env, a config file, a curl -v with an Authorization header, a stack trace, a log. Everything it reads is sent to the model and kept in the session transcript. A secret that gets in once is in the conversation for good.
secret-guard puts a checkpoint between your machine and the model:
ghp_…[40]) and a hash. The pane shows the value itself only when you press show the value, and hides it again after 30 seconds. The value is kept in the mod's memory alone: not in the session state, not on disk, never in a dialog.| Where text enters the context | Hook | On a finding |
|---|---|---|
| Output of any tool: Bash, Read, Grep, WebFetch, MCP tools, subagents | tool.call | Dialog: cut / hide whole output / pass / not a secret |
| Your own prompt (a pasted key) | prompt.submit | Dialog: cut / send as is / don't send (the text goes back to the input box) |
A file mentioned with @path | prompt.mention | Dialog: cut / don't attach / attach as is |
| CLAUDE.md, reminders, injected attachments (a changed-file note, a queued prompt…) | prompt.context, prompt.attachment | Cut automatically and journaled |
| The system prompt's sections | prompt.compose | Cut automatically and journaled |
| Every other row stored in the conversation | session.append | Safety net: cut automatically |
gitleaks also decodes base64, hex and percent-encoding (up to 5 levels), so cat .env | base64 is caught too. A line that carries an encoded secret is withheld as a whole.
gitleaks' default rules (about 200 known token shapes: ghp_…, sk_live_…, AWS keys, private keys…) are built for repositories. A conversation leaks differently, so secret-guard adds two layers of its own, each with an option:
| Layer | Catches | Option |
|---|---|---|
| Config assignments | a password in TOML, INI, .env, JSON or YAML: the value after a key that ends with password, passwd, pwd, passphrase, secret or _pass (Password = "…", DB_PASS=…, "adminPass": "…"), whatever its shape: a word-like or digits-only password is caught too. With : (YAML) the key must start a line (also in Read or grep -n output) or be a quoted JSON key, and an unquoted value must end the line, so prose like Password: enter it twice is left. password_policy = "strong", secret_name = "prod/db", password = os.getenv(…), ${VAR} and type hints are left | keywordRules |
| Keyword rules | a value after пароль / password / pwd / passphrase / секрет / secret / токен / token / ключ доступа / access key, in English or Russian, with up to three words before a separator (пароль от прод базы: …, the password is …); a password in a URL (postgres://user:…@host); a login:password pair after доступ / логин / креды / access / login / credentials (доступ к базе admin:…); a Bearer token or Authorization: Basic; a password on a command line (mysql -p…, sshpass -p …, curl -u user:…, --password …, PGPASSWORD=…); sk-proj- keys | keywordRules |
| Entropy rule | a long random-looking token with no known shape, by its Shannon entropy (20–128 characters, upper and lower case and digits, ≥ 4.0 bits per character): about 93% of random 20–64-character tokens | entropyRule |
| Random words in your prompt | a password typed with nothing around it (прод доступ 73Kd91a4qx!!!): a word of 8+ characters whose kind of character (lower, upper, digit, symbol) keeps changing. About 73% of such passwords; a dialog on 0.2% of ordinary prompts (measured on 594 real prompts). Every mark that is neither a letter nor a digit splits a word into parts, so names (ACME_DB_PASS, ^API_KEY=, process.env.TOKEN) are never cut: only values are. Prompts up to 2000 characters, and the record of a command you run with ! (the command itself runs as typed; the model reads it with values cut) | wordRule |
secret-guard never scans its own marks: placeholders and masks reach gitleaks as spaces of the same length, and its own dialogs are not checked. Only the value after a keyword is cut, never the word itself. There must be a space or a separator between them, so secret-guard/… or tokens/cache.json is a path, not a secret. Words (also in markdown emphasis, Jira), names made of plain parts (a branch bugfix/SHOP-4242-checkout-page, a ticket, a tag node-22-alpine-rc1), numbers, code (getenv(…)), templates (${X}), markup, hashes, UUIDs, SRI values, paths, a value that is just a keyword (password: password, a list of keyword names), a name= pair whose value is a short number or a plain word (Secret len=64, token: mode=strict); after any other name= the value is a secret, and only it is cut (token: AccountKey=…) and secret-guard's own placeholders are not treated as secrets. Both layers extend your project's .gitleaks.toml when it has one. A GITLEAKS_CONFIG you set yourself takes precedence, and then the extra layers are left out.
To see what the rules catch in a file of yours, run python3 scripts/explain.py path/to/config.toml: it prints each line's key, its value's shape (kinds of characters and length) and the rules that caught it, never a value.
scripts/check_rules.py checks the rules against real gitleaks in CI: 44 phrases that must be caught, 90 that must not, the rule files themselves (which must find nothing) (taken from real Claude Code sessions, lockfiles and git logs), and the entropy rule's recall.
A secret you have already cut once is cut again silently, and wherever it shows up verbatim, even where no rule would see it (a shell echoing a typed password in command not found: …). You are asked again only when a new secret shows up.
You need:
sh brew install gitleaks # macOS / Linux (Homebrew) # or: https://github.com/gitleaks/gitleaks#installing ``If gitleaks is missing, secret-guard says so at the start of a session and offers to install it: in the dialog (Install gitleaks and check, with Homebrew), with install gitleaks in the pane, or by telling the agent how to install it, in place of the output it withheld.
Then, at the Claude Code prompt:
/plugin install secret-guard --marketplace legostin/claude-code-secret-guard
Answer y to add the marketplace, then pick a scope (user scope protects every project). The hooks are active right away.
file:line, the lines around with the value masked, and show the value. It goes once you answer./secrets opens the side pane. It has two tabs.This session is the registry: every value met, listed once, with its status (cut without a question or the model sees it), how often it was seen and where last. For each value:
Forget all empties the registry. Placeholder numbers are never reused, so #3 never means two different values.
Log lists what happened, newest first. For each event:
file:line when the text says (a Read, a Grep match, a changed-file note, an @-file), otherwise the line in the text;GITHUB_TOKEN=[SECRET:github-pat#1]) and as it is in the file, value masked (GITHUB_TOKEN=ghp_…[40]).Press an event to open it: the whole source command and the whole file path. Clear the log only empties this list. Known secrets stay known.
All history shows the events of every session on this machine, grouped by session (date, project, session id), newest first, up to 400 events. It is kept in the plugin's store on disk: masks and the lines around, never a value or a hash (a short secret's hash could be brute-forced). Delete one session, or clear all of it.
The pane is drawn for you alone and is never part of the model's context. Don't paste a screenshot of it into the chat: images reach the model, and secret-guard does not scan images.
secret-guard: N hidden · /secrets once something has been withheld. If gitleaks is missing, it shows the install command./plugin configure secret-guard@secret-guard, or set it in settings.json: ``json { "pluginConfigs": { "secret-guard@secret-guard": { "options": { "language": "ru" } } } } `` What the model reads is always English..gitleaks.toml, .gitleaksignore and gitleaks:allow comments from the session's project root.[SECRET:github-pat#1] in place of a secret. The number stays the same for the same value throughout the session.[SECRET:github-pat#2: encoded secret, line withheld] in place of a line that carries an encoded secret.the user withheld this Bash output from you…) when you hide a whole output.What is verified (see tests/ and the live checks in docs/design.md):
claude -p) no dialog can be shown, so every finding is hidden.What it does not do:
пароль: …) a value is caught from 6 characters.planets scores as high as a password. The random-word rule measures how often the kind of character changes instead. scripts/words_experiment.py reproduces the numbers on your own prompts and prints only word shapes (aA9), never a word.password: hunterHunter), and a hex key looks like a commit hash. Both are let through, to keep the false positives down.entropyRule off if your agent reads those a lot.curl through Bash when that matters: there the output is checked before anything reads it./secrets.git clone https://github.com/legostin/claude-code-secret-guard
cd claude-code-secret-guard
claude plugin validate . # what the engine will load and refuse
claude plugin test . # the test suite (gitleaks and dialogs are stubbed)
claude --plugin-dir . # a session with the mod loaded from this folder
How the pieces fit:
hooks/register.tsx: the hooks, the scanner call, dialogs and the pane. Everything that touches the engine's $ has to live in this one module.hooks/redact.ts: pure helpers. Parses the gitleaks report, cuts secrets, builds masks and hashes.hooks/texts.ts: every string, in English and Russian.types/index.d.ts: the $.state contract (journal, allowlist, numbering, scanner status).The design and its trade-offs are in docs/design.md.
MIT © Legostin Vyacheslav
hooks/register.tsx 1339 lines1// secret-guard: gitleaks looks at everything about to enter the model's
2// context; what it finds is cut out, withheld or passed as the person decides.
3//
4// tool.call a tool's output: asks, then hides it or lets it on
5// session.append every row stored and sent: cuts what is not passed
6// prompt.submit the person's own prompt: asks before it is sent
7// prompt.mention an @-mentioned file: asks before it is attached
8// prompt.attachment files, reminders, hook output the engine injects
9// prompt.context CLAUDE.md and the first message's context blocks
10// prompt.compose the system prompt's sections
11//
12// Secrets live only in this module's memory, for as long as a text is being
13// checked. The journal and the allowlist hold masks and hashes.
14
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, EventOf, Register, ToolCallResult } from 'claude-code'
17
18import type { Decision, Entry, HistoryEntry, Known, Pending, Place } from '../types'
19import { excerpt, hashOf, mapTexts, mask, parseReport, placeholder, redact, redactRecord, uniqueByHash, withoutOwnMarks } from './redact'
20import { configWith } from './rules'
21import { randomWords } from './words'
22import type { RuleSet } from './rules'
23import type { Cut, Found, Leak } from './redact'
24import {
25 clock,
26 describeCall,
27 hiddenNote,
28 keyOf,
29 MENTION_FAILED,
30 MENTION_SKIPPED,
31 MISSING_NOTE,
32 NOTE,
33 pathIn,
34 shortPath,
35 stamp,
36 textsFor,
37 TOOL_FAILED,
38 withheld,
39} from './texts'
40
41const PANE = 'secret-guard'
42
43const entries = atom({ plugin: 'secret-guard', key: 'entries' } as const, [])
44const allowed = atom({ plugin: 'secret-guard', key: 'allowed' } as const, [])
45const known = atom({ plugin: 'secret-guard', key: 'known' } as const, {})
46const lastNumber = atom({ plugin: 'secret-guard', key: 'lastNumber' } as const, 0)
47const scanner = atom({ plugin: 'secret-guard', key: 'scanner' } as const, { status: 'unknown', detail: '' })
48const expanded = atom({ plugin: 'secret-guard', key: 'expanded' } as const, [])
49const revealed = atom({ plugin: 'secret-guard', key: 'revealed' } as const, [])
50const tab = atom({ plugin: 'secret-guard', key: 'tab' } as const, 'session')
51const pending = atom({ plugin: 'secret-guard', key: 'pending' } as const, null)
52const openedHistory = atom({ plugin: 'secret-guard', key: 'openedHistory' } as const, [])
53const historyVersion = atom({ plugin: 'secret-guard', key: 'historyVersion' } as const, 0)
54
55// The history kept across sessions, in the plugin's store.
56const HISTORY_KEY = 'history'
57const HISTORY_KEPT = 400
58const HISTORY_SHOWN = 150
59
60const GITLEAKS_ARGS = [
61 'stdin',
62 '--report-format', 'json',
63 '--report-path', '-',
64 '--no-banner',
65 '--log-level', 'error',
66 '--exit-code', '0',
67]
68const GITLEAKS_PATHS = ['gitleaks', '/opt/homebrew/bin/gitleaks', '/usr/local/bin/gitleaks']
69// Where an install by hand or by the agent leaves it, under the home folder.
70const GITLEAKS_HOME_PATHS = ['go/bin/gitleaks', '.local/bin/gitleaks', 'bin/gitleaks']
71const BREW_PATHS = ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew']
72const MIN_LENGTH = 8
73const CACHE_SIZE = 64
74const MAX_ENTRIES = 200
75
76// Rows no secret reaches: the model's own words, a compaction of rows already
77// checked, and notices the model never reads.
78const QUIET_DOORS: ReadonlySet<string> = new Set(['response', 'compaction', 'notice'])
79const ALLOWABLE: ReadonlySet<Decision> = new Set(['redacted', 'recut', 'hidden', 'dropped', 'auto-redacted'])
80// The model never read the value under these; it read a placeholder under
81// MODEL_READ, the value itself under SAW, and nothing at all under NOTHING.
82const CUT: ReadonlySet<Decision> = new Set(['redacted', 'recut', 'hidden', 'dropped', 'auto-redacted', 'withheld'])
83const MODEL_READ: ReadonlySet<Decision> = new Set(['redacted', 'recut', 'auto-redacted'])
84const SAW: ReadonlySet<Decision> = new Set(['passed', 'allowlisted'])
85const NOTHING: ReadonlySet<Decision> = new Set(['hidden', 'dropped', 'withheld'])
86// Lines shown around a finding before it is opened; opened, all that were kept.
87const AROUND_CLOSED = 3
88
89/** Why a scan did not happen: gitleaks is not there, the call was cut short, or it failed. */
90type ScanFailure = { isScanned: false; reason: string; kind: 'missing' | 'aborted' | 'failed' }
91type ScanResult = { isScanned: true; leaks: Leak[] } | ScanFailure
92
93/** A scan, or why there is none and what the person chose then. */
94type Checked = { isScanned: true; leaks: Leak[] } | (ScanFailure & { isPassed: boolean })
95
96/** The text a finding was made in, so the journal can say where it stood. */
97type Origin = { text: string; leaks: readonly Leak[]; file?: string; isFileText?: boolean }
98
99// A tool's output is scanned at tool.call and again as its row is appended:
100// the second look is answered from here, by the text's hash.
101const scans = new Map<string, Leak[]>()
102// Texts the person let through unchecked when the scanner failed.
103const passedTexts = new Set<string>()
104// The values the journal's secrets had, by hash, for the person to see in the
105// pane on request. Here alone: never in $.state, the store or a dialog, and
106// gone when the module reloads.
107const values = new Map<string, string>()
108const VALUES_KEPT = 200
109// A value cut before is looked for again only from this length, and only whole.
110const KNOWN_MIN = 6
111const REVEAL_MS = 30_000
112let gitleaks: string | undefined
113// Homebrew's path once looked for; null when there is none.
114let brew: string | null | undefined
115// What the person reads, in the language the `language` option names.
116let t = textsFor('en')
117// The rules added to gitleaks' own (the keywordRules and entropyRule options),
118// and the environment that hands them to gitleaks, by project root.
119let ruleSet: RuleSet = { keywords: true, entropy: true }
120// Random-looking words in the person's prompts (the wordRule option).
121let isWordRuleOn = true
122const environments = new Map<string, Record<string, string>>()
123
124export const register: Register = (on, options) => {
125 t = textsFor(options.language)
126 ruleSet = { keywords: options.keywordRules !== false, entropy: options.entropyRule !== false }
127 isWordRuleOn = options.wordRule !== false
128
129 on('session.start', async ($, e, next) => {
130 await $.command.register({ name: 'secrets', description: t.commandDescription })
131 await probe($)
132 if ((await read($, scanner)).status === 'missing') $.ui.toast(t.missingToast, { timeoutMs: 10_000 })
133
134 return next(e)
135 })
136
137 on('command.run', { command: 'secrets' }, async $ => {
138 await $.ui.open({ id: PANE, title: 'secret-guard' })
139
140 return { text: t.paneOpened }
141 })
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e))
144
145 // The system prompt: every section checked (env details, instructions other
146 // plugins add), then the guard's own note for the model.
147 on('prompt.compose', async ($, e, next) => {
148 const composed = await next(e)
149 const sections = []
150 for (const section of composed.sections) {
151 const text = await scrubAsking($, section.text, t.systemPrompt(section.id))
152 sections.push(text === section.text ? section : { ...section, text })
153 }
154
155 return { ...composed, sections: [...sections, { id: 'secret-guard', text: NOTE, scope: 'session' }] }
156 })
157
158 // A tool's output, after the tool ran and before the model reads it. A cut
159 // is made in the tool's own record, so neither the model nor the record the
160 // transcript keeps for the screen holds the secret; session.append checks
161 // the row once more as it is stored.
162 on('tool.call', async ($, e, next) => {
163 const ran = await next(e)
164 if (ran.deny !== undefined || ran.text === undefined || ran.text === '') return ran
165 // The guard's own dialog: its question names masks, never a value.
166 if (isOwnDialog(e as unknown as Record<string, unknown>)) return ran
167
168 const who = e.agentId === undefined ? '' : t.subagentPrefix
169 const source = describeCall(String(e.tool), e as unknown as Record<string, unknown>, who)
170 const result = await scanOrAsk($, ran.text, source)
171 if (!result.isScanned) {
172 if (result.isPassed) {
173 passedTexts.add(await hashOf(ran.text))
174 await recordFailure($, source, result.reason, 'passed')
175
176 return ran
177 }
178 if (result.kind !== 'aborted') await recordFailure($, source, result.reason, 'withheld')
179
180 return { deny: unchecked(result) }
181 }
182
183 const found = await unresolved($, result.leaks)
184 if (found.length === 0) return ran
185 const input = e as unknown as Record<string, unknown>
186 const origin: Origin = {
187 text: ran.text,
188 leaks: result.leaks,
189 ...(typeof input.file_path === 'string' ? { file: input.file_path } : {}),
190 }
191 const { known, novel } = await splitKnown($, found)
192 if (novel.length === 0) {
193 await record($, source, known, 'recut', origin)
194
195 return cutOutput($, ran, known, String(e.tool))
196 }
197
198 const choice = await askAbout($, source, novel, origin, t.toolQuestion(source, novel), t.toolOptions, 'redact', 'hide')
199 if (choice === 'hide') {
200 await numbersFor($, found)
201 await record($, source, found, 'hidden', origin)
202
203 return { deny: hiddenNote(String(e.tool), found) }
204 }
205 if (choice === 'pass' || choice === 'allow') {
206 const reason = choice === 'pass' ? 'passed' : 'allowlisted'
207 await allow($, novel, reason)
208 await record($, source, novel, reason, origin)
209 await record($, source, known, 'recut', origin)
210
211 return known.length === 0 ? ran : cutOutput($, ran, known, String(e.tool))
212 }
213 await numbersFor($, found)
214 await record($, source, novel, 'redacted', origin)
215 await record($, source, known, 'recut', origin)
216
217 return cutOutput($, ran, found, String(e.tool))
218 }).catch(($, e, next) =>
219 next.error.kind === 're-entry'
220 ? next(e)
221 : { deny: TOOL_FAILED },
222 )
223
224 // Every row the conversation keeps, before it is stored and sent: what is
225 // not passed is cut, and a text that cannot be checked is withheld whole.
226 on('session.append', async ($, e, next) => {
227 if (QUIET_DOORS.has(e.door) || e.message.type === 'system') return next(e)
228 const source = `${t.doors[e.door] ?? e.door}${e.agentId === undefined ? '' : t.subagentSuffix}`
229 const content = await mapTexts(e.message.content, text =>
230 // A `!` command the person ran is their own typing, as a prompt is.
231 scrubQuietly($, text, text.includes('<bash-input>') ? t.shellCommand : source, undefined, text.includes('<bash-input>')),
232 )
233
234 return content === undefined ? next(e) : next({ ...e, message: { ...e.message, content } })
235 }).catch(async ($, e, next) => {
236 if (next.called) return next(e)
237 const content = await mapTexts(e.message.content, async () => withheld('the check failed'))
238
239 return next({ ...e, message: { ...e.message, content: content ?? e.message.content } })
240 })
241
242 on('prompt.submit', async ($, e, next) => {
243 // A shell command the person runs with `!` runs as typed: its output, and
244 // the record of it the model reads, are checked as they are stored.
245 if (e.text.trimStart().startsWith('!')) return next(e)
246 const result = await scanOrAsk($, e.text, t.prompt)
247 if (!result.isScanned) {
248 if (result.isPassed) {
249 passedTexts.add(await hashOf(e.text))
250
251 return next(e)
252 }
253 if (result.kind !== 'aborted') await recordFailure($, t.prompt, result.reason, 'dropped')
254 await refill($, e.text)
255
256 return { drop: t.promptDroppedFailure(result.reason) }
257 }
258
259 const leaks = isWordRuleOn ? withWords(result.leaks, e.text) : result.leaks
260 const found = await unresolved($, leaks)
261 if (found.length === 0) return next(e)
262 const origin: Origin = { text: e.text, leaks }
263 const { known, novel } = await splitKnown($, found)
264 const choice =
265 novel.length === 0
266 ? 'redact'
267 : await askAbout($, t.prompt, novel, origin, t.promptQuestion(novel), t.promptOptions, 'redact', 'cancel')
268 if (choice === 'cancel') {
269 await numbersFor($, found)
270 await record($, t.prompt, found, 'dropped', origin)
271 await refill($, e.text)
272
273 return { drop: t.promptDropped }
274 }
275 if (choice === 'send' || choice === 'allow') {
276 const reason = choice === 'send' ? 'passed' : 'allowlisted'
277 await allow($, novel, reason)
278 await record($, t.prompt, novel, reason, origin)
279 }
280 const cut = choice === 'redact' ? found : known
281 const cuts = await cutsFor($, cut)
282 if (choice === 'redact') await record($, t.prompt, novel, 'redacted', origin)
283 await record($, t.prompt, known, 'recut', origin)
284
285 return next({ ...e, text: redact(e.text, cuts) })
286 }).catch(($, e, next) =>
287 next.called ? next(e) : { drop: t.promptFailed },
288 )
289
290 // An @-mentioned file, before the engine reads it. Its text reaches the
291 // model as an attachment, which prompt.attachment cuts.
292 on('prompt.mention', async ($, e, next) => {
293 let text: string
294 try {
295 const content = await $.fs.read(e.path)
296 if (typeof content !== 'string') return next(e)
297 text = content
298 } catch {
299 return next(e)
300 }
301 const result = await scanText($, text)
302 if (!result.isScanned) return next(e)
303
304 const found = await unresolved($, result.leaks)
305 const { novel } = await splitKnown($, found)
306 if (novel.length === 0) return next(e)
307
308 const source = `@${e.mention}`
309 const origin: Origin = { text, leaks: result.leaks, file: e.path, isFileText: true }
310 const choice = await askAbout($, source, novel, origin, t.mentionQuestion(source, novel), t.mentionOptions, 'redact', 'skip')
311 if (choice === 'skip') {
312 await numbersFor($, found)
313 await record($, source, found, 'dropped', origin)
314 $.ui.toast(t.mentionSkipped(source))
315
316 return { deny: MENTION_SKIPPED }
317 }
318 if (choice === 'pass' || choice === 'allow') {
319 const reason = choice === 'pass' ? 'passed' : 'allowlisted'
320 await allow($, novel, reason)
321 await record($, source, novel, reason, origin)
322
323 return next(e)
324 }
325 await numbersFor($, found)
326 await record($, source, found, 'redacted', origin)
327
328 return next(e)
329 }).catch(($, e, next) =>
330 next.called ? next(e) : { deny: MENTION_FAILED },
331 )
332
333 on('prompt.attachment', async ($, e, next) => {
334 const ran = await next(e)
335 if (ran.text === null || ran.text === '') return ran
336
337 const file = pathIn(ran.text)
338 const where = file === undefined ? undefined : shortPath(file, await $.session.root())
339
340 return { text: await scrubAsking($, ran.text, t.attachment(e.type, where), file) }
341 }).catch(() => ({ text: withheld('the check of this attachment failed') }))
342
343 on('prompt.context', async ($, e, next) => {
344 const ran = await next(e)
345 let isChanged = false
346 const blocks = []
347 for (const block of ran.blocks) {
348 const text = await scrubAsking($, block.text, t.context(block.name))
349 isChanged ||= text !== block.text
350 blocks.push(text === block.text ? block : { ...block, text })
351 }
352
353 return isChanged ? { ...ran, blocks } : ran
354 }).catch(() => ({ blocks: [] }))
355}
356
357// --- scanning --------------------------------------------------------------
358
359/** Runs gitleaks over a text, and keeps the status line in step with it. */
360async function scanText($: EngineInterface, text: string): Promise<ScanResult> {
361 if (text.length < MIN_LENGTH) return { isScanned: true, leaks: [] }
362 const key = await hashOf(text)
363 const hit = scans.get(key)
364 if (hit !== undefined) return { isScanned: true, leaks: await knownIn($, text, hit) }
365
366 const cwd = await $.session.root()
367 const env = await gitleaksEnvironment($, cwd)
368 let reason = t.notFound
369 let isMissing = true
370 for (const bin of gitleaks === undefined ? await gitleaksPaths($) : [gitleaks]) {
371 let ran
372 try {
373 ran = await $.process.run([bin, ...GITLEAKS_ARGS], { cwd, env, stdin: withoutOwnMarks(text), timeoutMs: 20_000 })
374 } catch (error) {
375 const message = messageOf(error)
376 // Cut short (the person interrupted, the step was abandoned): no fault of gitleaks'.
377 if (/abort/i.test(message)) return { isScanned: false, reason: t.aborted, kind: 'aborted' }
378 // Not started at any path is gitleaks missing; a timeout is gitleaks failing.
379 isMissing &&= !/time|still running/i.test(message)
380 reason = `${bin}: ${message}`
381 continue
382 }
383 if (ran.exitCode !== 0) {
384 return { isScanned: false, reason: t.exited(ran.exitCode, firstLine(ran.stderr)), kind: 'failed' }
385 }
386 let leaks: Leak[]
387 try {
388 leaks = parseReport(ran.stdout)
389 } catch (error) {
390 return { isScanned: false, reason: t.unreadable(messageOf(error)), kind: 'failed' }
391 }
392 scans.set(key, leaks)
393 if (scans.size > CACHE_SIZE) scans.delete(scans.keys().next().value ?? '')
394 if (gitleaks !== bin) {
395 gitleaks = bin
396 await update($, scanner, () => ({ status: 'ok', detail: bin }))
397 await refreshStatus($)
398 }
399
400 return { isScanned: true, leaks: await knownIn($, text, leaks) }
401 }
402 gitleaks = undefined
403 if (!isMissing) return { isScanned: false, reason, kind: 'failed' }
404 if ((await read($, scanner)).status !== 'installing') {
405 await update($, scanner, () => ({ status: 'missing', detail: t.missing }))
406 await refreshStatus($)
407 }
408
409 return { isScanned: false, reason: t.notFound, kind: 'missing' }
410}
411
412/** Where gitleaks may be: on PATH, Homebrew's folders, and the home folder's. */
413async function gitleaksPaths($: EngineInterface): Promise<string[]> {
414 const home = await $.env.get('HOME')
415
416 return [...GITLEAKS_PATHS, ...(home === undefined ? [] : GITLEAKS_HOME_PATHS.map(path => `${home}/${path}`))]
417}
418
419/** Homebrew's path, looked for once; undefined when there is none. */
420async function brewPath($: EngineInterface): Promise<string | undefined> {
421 if (brew !== undefined) return brew ?? undefined
422 for (const bin of BREW_PATHS) {
423 try {
424 if ((await $.process.run([bin, '--version'], { timeoutMs: 10_000 })).exitCode === 0) {
425 brew = bin
426
427 return bin
428 }
429 } catch {
430 // not at this path; try the next
431 }
432 }
433 brew = null
434
435 return undefined
436}
437
438/** Installs gitleaks with Homebrew. Undefined when it is in place; else why not. */
439async function install($: EngineInterface): Promise<string | undefined> {
440 const bin = await brewPath($)
441 if (bin === undefined) return t.noBrew
442 await update($, scanner, () => ({ status: 'installing', detail: t.installing }))
443 $.ui.toast(t.installing, { timeoutMs: 15_000 })
444 let failure: string | undefined
445 try {
446 const ran = await $.process.run([bin, 'install', 'gitleaks'], {
447 timeoutMs: 600_000,
448 env: { HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' },
449 })
450 if (ran.exitCode !== 0) failure = t.installFailed(lastLine(ran.stderr || ran.stdout))
451 } catch (error) {
452 failure = t.installFailed(messageOf(error))
453 }
454 await probe($)
455 if (failure === undefined && (await read($, scanner)).status !== 'ok') failure = t.missing
456 $.ui.toast(failure ?? t.installed)
457
458 return failure
459}
460
461/**
462 * Scans, and when gitleaks is not there or fails asks the person: install it
463 * and check (when Homebrew is there), hide the text, or pass it unchecked.
464 * A check cut short asks nothing: what it guarded was abandoned with it.
465 */
466async function scanOrAsk($: EngineInterface, text: string, source: string): Promise<Checked> {
467 const first = await scanText($, text)
468 if (first.isScanned) return first
469 if (first.kind === 'aborted') return { ...first, isPassed: false }
470
471 const canInstall = first.kind === 'missing' && (await brewPath($)) !== undefined
472 const choice = canInstall
473 ? await ask($, t.missingQuestion(source), t.missingOptions, 'hide', 'hide')
474 : await ask($, t.failureQuestion(source, first.reason), t.failureOptions, 'hide', 'hide')
475 if (choice !== 'install') return { ...first, isPassed: choice === 'pass' }
476
477 const failure = await install($)
478 if (failure !== undefined) return { isScanned: false, reason: failure, kind: 'missing', isPassed: false }
479 const again = await scanText($, text)
480
481 return again.isScanned ? again : { ...again, isPassed: false }
482}
483
484/** What the model reads in place of a text that was not checked. */
485function unchecked(failure: ScanFailure): string {
486 return failure.kind === 'missing' ? MISSING_NOTE : withheld(failure.reason)
487}
488
489/**
490 * The environment gitleaks runs in: the guard's rules, as GITLEAKS_CONFIG_TOML,
491 * over the project's `.gitleaks.toml` when there is one (gitleaks would read
492 * that file otherwise) and over the default set when not. A GITLEAKS_CONFIG
493 * the person set outranks it, so then the guard adds nothing and theirs rules.
494 */
495async function gitleaksEnvironment($: EngineInterface, root: string): Promise<Record<string, string>> {
496 const kept = environments.get(root)
497 if (kept !== undefined) return kept
498 let env: Record<string, string> = {}
499 if ((ruleSet.keywords || ruleSet.entropy) && (await $.env.get('GITLEAKS_CONFIG')) === undefined) {
500 const own = `${root}/.gitleaks.toml`
501 const base = (await $.fs.exists(own)) ? own : undefined
502 env = { GITLEAKS_CONFIG_TOML: configWith(base, ruleSet) }
503 }
504 environments.set(root, env)
505
506 return env
507}
508
509/**
510 * The scan's leaks, and the values cut before that stand in `text` as they
511 * are: a secret cut once is cut wherever it shows again, a shell's echo of a
512 * typed password ("command not found: …") included, where no rule would see
513 * it. Values are kept in this module's memory alone (see `values`).
514 */
515async function knownIn($: EngineInterface, text: string, leaks: readonly Leak[]): Promise<Leak[]> {
516 if (values.size === 0) return [...leaks]
517 const registry = await read($, known)
518 const extra: Leak[] = []
519 for (const [hash, value] of values) {
520 const one = registry[hash]
521 if (one === undefined || one.number === 0 || value.length < KNOWN_MIN) continue
522 const at = standalone(text, value)
523 if (at < 0) continue
524 if (leaks.some(leak => leak.secret.includes(value))) continue
525 const startLine = text.slice(0, at).split('\n').length
526 extra.push({ rule: one.rule, secret: value, startLine, endLine: startLine + value.split('\n').length - 1, isEncoded: false })
527 }
528
529 return [...leaks, ...extra]
530}
531
532/**
533 * Where `value` stands in `text` as a whole: no letter or digit right before
534 * or after it, so a value is not found inside a longer word. -1 when nowhere.
535 */
536function standalone(text: string, value: string): number {
537 const isWordChar = (ch: string | undefined) => ch !== undefined && /[\p{L}\p{Nd}]/u.test(ch)
538 for (let at = text.indexOf(value); at >= 0; at = text.indexOf(value, at + 1)) {
539 if (!isWordChar(text[at - 1]) && !isWordChar(text[at + value.length])) return at
540 }
541
542 return -1
543}
544
545/** Finds the installed gitleaks and says so in the pane and status line. */
546async function probe($: EngineInterface): Promise<void> {
547 for (const bin of await gitleaksPaths($)) {
548 try {
549 const ran = await $.process.run([bin, 'version'], { timeoutMs: 10_000 })
550 if (ran.exitCode === 0) {
551 gitleaks = bin
552 await update($, scanner, () => ({ status: 'ok', detail: `gitleaks ${ran.stdout.trim()}` }))
553 await refreshStatus($)
554
555 return
556 }
557 } catch {
558 // not at this path; try the next
559 }
560 }
561 await update($, scanner, () => ({ status: 'missing', detail: t.missing }))
562 await refreshStatus($)
563}
564
565/** An AskUserQuestion call the guard raised itself, by the header it gives its dialogs. */
566function isOwnDialog(input: Record<string, unknown>): boolean {
567 const questions = input.questions
568
569 return (
570 input.tool === 'AskUserQuestion' &&
571 Array.isArray(questions) &&
572 questions.some(one => (one as { header?: unknown } | null)?.header === t.header)
573 )
574}
575
576/** gitleaks' leaks and the random-looking words it did not already cover. */
577function withWords(leaks: readonly Leak[], text: string): Leak[] {
578 const words = randomWords(text).filter(
579 word => !leaks.some(leak => leak.secret.includes(word.secret) || word.secret.includes(leak.secret)),
580 )
581
582 return [...leaks, ...words]
583}
584
585// --- what the model may read ----------------------------------------------
586
587/** The leaks the model may not read: those neither passed nor allowlisted. */
588async function unresolved($: EngineInterface, leaks: readonly Leak[]): Promise<Found[]> {
589 const ok = new Set((await read($, allowed)).map(one => one.hash))
590 const found: Found[] = []
591 for (const leak of leaks) {
592 const hash = await hashOf(leak.secret)
593 if (!ok.has(hash)) found.push({ leak, hash })
594 }
595
596 return found
597}
598
599/** Secrets cut before (cut again without asking) and new ones (asked about). */
600async function splitKnown($: EngineInterface, found: readonly Found[]): Promise<{ known: Found[]; novel: Found[] }> {
601 const registry = await read($, known)
602 const isCut = (one: Found) => (registry[one.hash]?.number ?? 0) > 0
603
604 return { known: found.filter(isCut), novel: found.filter(one => !isCut(one)) }
605}
606
607/**
608 * Gives each secret the number the model reads it under, keeping the ones it
609 * has. Numbers come from a counter that only grows, so a secret forgotten and
610 * met again, or a new one after it, never takes a number the model has read
611 * for another value.
612 */
613async function numbersFor($: EngineInterface, found: readonly Found[]): Promise<Record<string, number>> {
614 const registry = await read($, known)
615 const lacking = uniqueByHash(found).filter(one => (registry[one.hash]?.number ?? 0) === 0)
616 if (lacking.length > 0) {
617 let first = 0
618 await update($, lastNumber, last => {
619 first = last + 1
620
621 return last + lacking.length
622 })
623 const at = await $.clock.now()
624 await update($, known, current => {
625 const next = { ...current }
626 lacking.forEach(({ leak, hash }, index) => {
627 const had = next[hash]
628 if ((had?.number ?? 0) > 0) return
629 next[hash] = had === undefined
630 ? { hash, number: first + index, rule: leak.rule, mask: mask(leak.secret), firstAt: at, lastAt: at, seen: 0, lastSource: '' }
631 : { ...had, number: first + index }
632 })
633
634 return next
635 })
636 }
637
638 return numbersOf(await read($, known))
639}
640
641function numbersOf(registry: Record<string, Known>): Record<string, number> {
642 const numbers: Record<string, number> = {}
643 for (const one of Object.values(registry)) if (one.number > 0) numbers[one.hash] = one.number
644
645 return numbers
646}
647
648async function cutsFor($: EngineInterface, found: readonly Found[]): Promise<Cut[]> {
649 const numbers = await numbersFor($, found)
650 const rules = new Map(uniqueByHash(found).map(one => [one.hash, one.leak.rule]))
651
652 return found.map(({ leak, hash }) => ({ leak, rule: rules.get(hash) ?? leak.rule, number: numbers[hash] ?? 0 }))
653}
654
655/**
656 * A tool's answer with the secrets cut out of its record: the model reads the
657 * record mapped by the tool's own mapper, and the transcript keeps it for the
658 * screen. An error's text is cut and given as the call's refusal, which the
659 * model also reads as an error. Withheld whole when it cannot be cut.
660 */
661async function cutOutput(
662 $: EngineInterface,
663 ran: ToolCallResult,
664 found: readonly Found[],
665 tool: string,
666): Promise<ToolCallResult> {
667 const cuts = await cutsFor($, found)
668 const text = ran.text ?? ''
669 if (ran.isError === true) return { deny: redact(text, cuts) }
670 const result = redactRecord(ran.result, text, cuts)
671 if (result === undefined) return { deny: hiddenNote(tool, found) }
672
673 return ran.context === undefined ? { result } : { result, context: ran.context }
674}
675
676/** Lets the model read these secrets from now on. */
677async function allow($: EngineInterface, found: readonly Found[], reason: 'passed' | 'allowlisted'): Promise<void> {
678 await update($, allowed, list => {
679 const have = new Set(list.map(one => one.hash))
680 const added = uniqueByHash(found)
681 .filter(one => !have.has(one.hash))
682 .map(({ leak, hash }) => ({ hash, rule: leak.rule, mask: mask(leak.secret), reason }))
683
684 return [...list, ...added]
685 })
686}
687
688/**
689 * Cuts the secrets out of a text where no dialog can be shown: a new secret
690 * is cut and journaled, a known one cut again, and a text the scanner could
691 * not check is withheld whole.
692 */
693async function scrubQuietly(
694 $: EngineInterface,
695 text: string,
696 source: string,
697 file?: string,
698 isTyped = false,
699): Promise<string> {
700 if (passedTexts.has(await hashOf(text))) return text
701 const result = await scanText($, text)
702 if (!result.isScanned) {
703 if (result.kind !== 'aborted') {
704 await recordFailure($, source, result.reason, 'withheld')
705 $.ui.toast(result.kind === 'missing' ? t.missingToast : t.withheldToast(source))
706 }
707
708 return unchecked(result)
709 }
710 const leaks = isTyped && isWordRuleOn ? withWords(result.leaks, text) : result.leaks
711
712 return cutFound($, source, await unresolved($, leaks), { text, leaks, file })
713}
714
715/** As scrubQuietly, but a text the scanner could not check is the person's to pass. */
716async function scrubAsking($: EngineInterface, text: string, source: string, file?: string): Promise<string> {
717 if (passedTexts.has(await hashOf(text))) return text
718 const result = await scanOrAsk($, text, source)
719 if (!result.isScanned) {
720 if (result.isPassed) {
721 passedTexts.add(await hashOf(text))
722 await recordFailure($, source, result.reason, 'passed')
723
724 return text
725 }
726 if (result.kind !== 'aborted') await recordFailure($, source, result.reason, 'withheld')
727
728 return unchecked(result)
729 }
730
731 return cutFound($, source, await unresolved($, result.leaks), { text, leaks: result.leaks, file })
732}
733
734async function cutFound($: EngineInterface, source: string, found: readonly Found[], origin: Origin): Promise<string> {
735 const { text } = origin
736 if (found.length === 0) return text
737 const { known, novel } = await splitKnown($, found)
738 const cuts = await cutsFor($, found)
739 await record($, source, known, 'recut', origin)
740 if (novel.length > 0) {
741 await record($, source, novel, 'auto-redacted', origin)
742 $.ui.toast(t.autoCut(novel[0]?.leak.rule ?? 'secret', source))
743 }
744
745 return redact(text, cuts)
746}
747
748// --- asking -------------------------------------------------------------
749
750/**
751 * Pauses until the person picks an option. A dismissed dialog answers
752 * `dismissed`; free text typed under "Other" answers `fallback`.
753 */
754async function ask<O extends Record<string, string>>(
755 $: EngineInterface,
756 question: string,
757 options: O,
758 fallback: keyof O & string,
759 dismissed: keyof O & string,
760): Promise<keyof O & string> {
761 try {
762 const answer = await $.ui.ask(question, { header: t.header, options: Object.values<string>(options) })
763
764 return keyOf(options, answer, fallback)
765 } catch {
766 return dismissed
767 }
768}
769
770
771async function refill($: EngineInterface, text: string): Promise<void> {
772 try {
773 await $.prompt.fill({ text, mode: 'replace' })
774 } catch {
775 // the box is the person's; nothing to undo
776 }
777}
778
779// --- the journal ----------------------------------------------------------
780
781/**
782 * Where each leak of `origin`'s text stood, as the pane shows it: the file
783 * and line when the text says which, and the lines around it as the model
784 * read them, its placeholder where it was cut, its mask where it was not
785 * (or not yet). The pane never shows a value this way.
786 */
787async function placesIn($: EngineInterface, origin: Origin | undefined): Promise<(leak: Leak) => Place> {
788 if (origin === undefined) return () => ({})
789 const numbers = numbersOf(await read($, known))
790 const root = await $.session.root()
791 const passed = new Set((await read($, allowed)).map(one => one.hash))
792 const hashes = new Map<string, string>()
793 for (const leak of origin.leaks) hashes.set(leak.secret, await hashOf(leak.secret))
794 const withHashes = origin.leaks.map(leak => ({ leak, hash: hashes.get(leak.secret) ?? '' }))
795 const rules = new Map(uniqueByHash(withHashes).map(one => [one.hash, one.leak.rule]))
796 const labelOf = (leak: Leak, isEncodedLine: boolean) => {
797 const hash = hashes.get(leak.secret) ?? ''
798 const number = numbers[hash]
799 if (number === undefined || passed.has(hash)) return isEncodedLine ? '[encoded secret]' : mask(leak.secret)
800
801 return placeholder(rules.get(hash) ?? leak.rule, number, isEncodedLine)
802 }
803
804 return leak => {
805 const where = excerpt(origin.text, origin.leaks, leak, labelOf, { file: origin.file, isFileText: origin.isFileText })
806
807 return {
808 ...(where.file === undefined ? {} : { file: shortPath(where.file, root), filePath: where.file }),
809 line: where.line,
810 isFileLine: where.isFileLine,
811 isNumbered: where.isNumbered,
812 lines: where.lines,
813 }
814 }
815}
816
817/**
818 * Asks about secrets found in `origin`, showing them in the pane while the
819 * dialog is open: the pane opens on its own, and the finding stands at its
820 * top until the person answers.
821 */
822async function askAbout<O extends Record<string, string>>(
823 $: EngineInterface,
824 source: string,
825 found: readonly Found[],
826 origin: Origin,
827 question: string,
828 options: O,
829 fallback: keyof O & string,
830 dismissed: keyof O & string,
831): Promise<keyof O & string> {
832 const placeOf = await placesIn($, origin)
833 for (const { leak, hash } of found) values.set(hash, leak.secret)
834 const shown: Pending = {
835 source,
836 at: await $.clock.now(),
837 items: uniqueByHash(found).map(({ leak, hash }) => ({ hash, rule: leak.rule, mask: mask(leak.secret), ...placeOf(leak) })),
838 }
839 await update($, pending, () => shown)
840 try {
841 await $.ui.open({ id: PANE, title: 'secret-guard' })
842 } catch {
843 // the pane is a help, never a condition of asking
844 }
845 try {
846 return await ask($, question, options, fallback, dismissed)
847 } finally {
848 await update($, pending, () => null)
849 }
850}
851
852/**
853 * Adds one journal row per secret, with where it stood in `origin`'s text:
854 * the file and line when the text says which, and the lines around it with
855 * every secret masked.
856 */
857async function record(
858 $: EngineInterface,
859 source: string,
860 found: readonly Found[],
861 decision: Decision,
862 origin?: Origin,
863): Promise<void> {
864 if (found.length === 0) return
865 const numbers = numbersOf(await read($, known))
866 const at = await $.clock.now()
867 const placeOf = await placesIn($, origin)
868 for (const { leak, hash } of found) {
869 values.delete(hash)
870 values.set(hash, leak.secret)
871 }
872 while (values.size > VALUES_KEPT) values.delete(values.keys().next().value ?? '')
873 await update($, known, current => {
874 const next = { ...current }
875 for (const { leak, hash } of uniqueByHash(found)) {
876 const had = next[hash]
877 next[hash] = {
878 hash,
879 number: had?.number ?? 0,
880 rule: had?.rule ?? leak.rule,
881 mask: had?.mask ?? mask(leak.secret),
882 firstAt: had?.firstAt ?? at,
883 lastAt: at,
884 seen: (had?.seen ?? 0) + 1,
885 lastSource: source,
886 }
887 }
888
889 return next
890 })
891 let added: Entry[] = []
892 await update($, entries, list => {
893 let seq = list.at(-1)?.seq ?? 0
894 added = uniqueByHash(found).map(({ leak, hash }) => ({
895 seq: ++seq,
896 label: numbers[hash] ?? 0,
897 at,
898 source,
899 rule: leak.rule,
900 mask: mask(leak.secret),
901 hash,
902 decision,
903 ...placeOf(leak),
904 }))
905
906 return [...list, ...added].slice(-MAX_ENTRIES)
907 })
908 await keepInHistory($, added)
909 await refreshStatus($)
910}
911
912async function recordFailure($: EngineInterface, source: string, reason: string, decision: Decision): Promise<void> {
913 const at = await $.clock.now()
914 let row: Entry | undefined
915 await update($, entries, list => {
916 const seq = (list.at(-1)?.seq ?? 0) + 1
917 row = { seq, label: 0, at, source, rule: t.unavailable, mask: reason, hash: '', decision }
918
919 return [...list, row].slice(-MAX_ENTRIES)
920 })
921 await keepInHistory($, row === undefined ? [] : [row])
922 await refreshStatus($)
923}
924
925/**
926 * Adds journal rows to the history kept across sessions: no hash, and only
927 * the lines around the secret. The history is a convenience: a store that
928 * fails or is full loses the event, never a check.
929 */
930async function keepInHistory($: EngineInterface, rows: readonly Entry[]): Promise<void> {
931 if (rows.length === 0) return
932 const session = await $.session.id()
933 const project = (await $.session.root()).split('/').filter(Boolean).at(-1) ?? ''
934 const kept: HistoryEntry[] = rows.map(({ hash: _hash, seq, lines, ...row }) => ({
935 ...row,
936 id: `${session}:${seq}`,
937 session,
938 project,
939 ...(lines === undefined ? {} : { lines: nearHits(lines) }),
940 }))
941 try {
942 const before = asHistory(await $.store.get(HISTORY_KEY))
943 await $.store.set(HISTORY_KEY, [...before, ...kept].slice(-HISTORY_KEPT))
944 } catch {
945 return
946 }
947 await update($, historyVersion, version => version + 1)
948}
949
950function asHistory(value: unknown): HistoryEntry[] {
951 return Array.isArray(value) ? (value as HistoryEntry[]) : []
952}
953
954/** The lines within AROUND_CLOSED of the secret, each cut to 200 characters. */
955function nearHits(lines: NonNullable<Entry['lines']>): NonNullable<Entry['lines']> {
956 const hits = lines.filter(line => line.isHit).map(line => line.n)
957 const from = Math.min(...hits) - AROUND_CLOSED
958 const to = Math.max(...hits) + AROUND_CLOSED
959 const clip = (text: string) => (text.length > 200 ? `${text.slice(0, 199)}…` : text)
960
961 return lines
962 .filter(line => line.n >= from && line.n <= to)
963 .map(line => ({ ...line, text: clip(line.text), ...(line.inFile === undefined ? {} : { inFile: clip(line.inFile) }) }))
964}
965
966async function refreshStatus($: EngineInterface): Promise<void> {
967 const state = await read($, scanner)
968 if (state.status === 'missing') {
969 $.ui.status(`secret-guard: ${state.detail}`)
970
971 return
972 }
973 const passing = new Set((await read($, allowed)).map(one => one.hash))
974 const hidden = Object.values(await read($, known)).filter(one => one.number > 0 && !passing.has(one.hash)).length
975 $.ui.status(hidden > 0 ? t.status(hidden) : undefined)
976}
977
978// --- the pane ---------------------------------------------------------------
979
980async function drawPane($: EngineInterface, e: EventOf['ui.render']) {
981 const { Box, Text, Button } = $.ui.resolve(e)
982 const journal = await read($, entries)
983 const allowList = await read($, allowed)
984 const registry = Object.values(await read($, known)).sort((a, b) => b.lastAt - a.lastAt)
985 const state = await read($, scanner)
986 const opened = new Set(await read($, expanded))
987 const openedPast = new Set(await read($, openedHistory))
988 const shownValues = new Set(await read($, revealed))
989 const activeTab = await read($, tab)
990 const asking = await read($, pending)
991 await read($, historyVersion)
992 const allowedBy = new Map(allowList.map(one => [one.hash, one.reason]))
993 const width = Math.max(24, 'bodyColumns' in e.props && typeof e.props.bodyColumns === 'number' ? e.props.bodyColumns : 60)
994 const shown = journal.slice(-50).reverse()
995
996 const toggle = (seq: number) =>
997 update($, expanded, list => (list.includes(seq) ? list.filter(one => one !== seq) : [...list, seq]))
998 const hideValue = (hash: string) => update($, revealed, list => list.filter(one => one !== hash))
999 const showValue = async (hash: string) => {
1000 await update($, revealed, list => (list.includes(hash) ? list : [...list, hash]))
1001 $.clock.after(REVEAL_MS, () => hideValue(hash))
1002 }
1003 const allowHash = async (hash: string, rule: string, shownMask: string) => {
1004 await update($, allowed, list =>
1005 list.some(one => one.hash === hash) ? list : [...list, { hash, rule, mask: shownMask, reason: 'allowlisted' as const }],
1006 )
1007 await refreshStatus($)
1008 }
1009 const cutAgain = async (hash: string) => {
1010 await update($, allowed, list => list.filter(one => one.hash !== hash))
1011 await refreshStatus($)
1012 }
1013 const forget = async (hash: string) => {
1014 await update($, known, current => {
1015 const next = { ...current }
1016 delete next[hash]
1017
1018 return next
1019 })
1020 await update($, allowed, list => list.filter(one => one.hash !== hash))
1021 await update($, revealed, list => list.filter(one => one !== hash))
1022 values.delete(hash)
1023 await refreshStatus($)
1024 }
1025 const forgetAll = async () => {
1026 await update($, known, () => ({}))
1027 await update($, allowed, () => [])
1028 await update($, revealed, () => [])
1029 values.clear()
1030 await refreshStatus($)
1031 }
1032 const clearLog = async () => {
1033 await update($, entries, () => [])
1034 await update($, expanded, () => [])
1035 await refreshStatus($)
1036 }
1037
1038 const drawValue = (hash: string) => {
1039 const value = values.get(hash)
1040 if (value === undefined || !shownValues.has(hash)) return undefined
1041
1042 return (
1043 <Box flexDirection="column" marginTop={1}>
1044 <Text wrap="wrap" color="warning" bold>
1045 {t.pane.valueLabel} {value}
1046 </Text>
1047 <Text wrap="wrap" dimColor>
1048 {t.pane.valueWarning}
1049 </Text>
1050 </Box>
1051 )
1052 }
1053 const revealButton = (hash: string, key: string) =>
1054 values.get(hash) === undefined ? (
1055 <Text dimColor>{t.pane.valueGone}</Text>
1056 ) : (
1057 <Button
1058 key={key}
1059 label={shownValues.has(hash) ? t.pane.hideValue : t.pane.showValue}
1060 onPress={() => (shownValues.has(hash) ? hideValue(hash) : showValue(hash))}
1061 />
1062 )
1063
1064 const drawSecret = (one: Known) => {
1065 const passing = allowedBy.get(one.hash)
1066 const status = passing === undefined ? (one.number > 0 ? t.pane.statusCut : t.pane.statusNew) : passing === 'passed' ? t.pane.statusPassed : t.pane.statusAllowed
1067
1068 return (
1069 <Box key={`secret-${one.hash}`} flexDirection="column" marginTop={1}>
1070 <Text wrap="wrap" bold>
1071 {one.rule}
1072 {one.number > 0 ? ` #${one.number}` : ''} {one.mask}
1073 </Text>
1074 <Text wrap="wrap" color={passing === undefined ? 'success' : 'warning'}>
1075 {status}
1076 </Text>
1077 <Text wrap="truncate-end" dimColor>
1078 {t.pane.seen(one.seen)} · {t.pane.last(clock(one.lastAt), one.lastSource)}
1079 </Text>
1080 {drawValue(one.hash)}
1081 <Box flexDirection="row" gap={1}>
1082 {revealButton(one.hash, `reveal-secret-${one.hash}`)}
1083 {passing === undefined ? (
1084 <Button key={`allow-secret-${one.hash}`} label={t.pane.allowButton} onPress={() => allowHash(one.hash, one.rule, one.mask)} />
1085 ) : (
1086 <Button key={`cut-secret-${one.hash}`} label={t.pane.cutButton} onPress={() => cutAgain(one.hash)} />
1087 )}
1088 <Button key={`forget-${one.hash}`} label={t.pane.forgetButton} onPress={() => forget(one.hash)} />
1089 </Box>
1090 </Box>
1091 )
1092 }
1093
1094 const drawEntry = (entry: EntryView, key: string, isOpen: boolean, onToggle: () => unknown) => {
1095 const lines = entry.lines ?? []
1096 const hits = lines.filter(line => line.isHit)
1097 const firstHit = hits[0]?.n ?? 0
1098 const lastHit = hits.at(-1)?.n ?? 0
1099 const reach = isOpen ? Infinity : AROUND_CLOSED
1100 const window = lines.filter(line => line.n >= firstHit - reach && line.n <= lastHit + reach)
1101 const place =
1102 entry.line === undefined
1103 ? undefined
1104 : entry.file !== undefined && entry.isFileLine === true
1105 ? t.pane.at(entry.file, entry.line)
1106 : `${entry.file === undefined ? '' : `${entry.file} · `}${t.pane.textLine(entry.line)}`
1107 const title = `${isOpen ? '▾' : '▸'} ${clock(entry.at)} ${entry.rule}${entry.label > 0 ? ` #${entry.label}` : ''} ${entry.mask}`
1108 const legend = NOTHING.has(entry.decision) ? t.pane.legendNothing : SAW.has(entry.decision) ? t.pane.legendSaw : t.pane.legendRead
1109 const numberOf = (n: number) => (entry.isNumbered === true ? '' : `${String(n).padStart(4)} `)
1110 const isUnread = NOTHING.has(entry.decision)
1111 const hash = entry.hash === undefined || entry.hash === '' ? undefined : entry.hash
1112
1113 return (
1114 <Box key={`entry-${key}`} flexDirection="column" marginTop={1}>
1115 <Button key={`open-${key}`} plain label={title} onPress={onToggle} />
1116 <Text wrap="wrap" color={CUT.has(entry.decision) ? 'success' : 'warning'}>
1117 {t.decisions[entry.decision]}
1118 </Text>
1119 {place !== undefined && <Text wrap={isOpen ? 'wrap' : 'truncate-middle'}>{place}</Text>}
1120 <Text wrap={isOpen ? 'wrap' : 'truncate-end'} dimColor>
1121 {isOpen ? `${t.pane.sourceLabel} ${entry.source}` : entry.source}
1122 </Text>
1123 {isOpen && entry.filePath !== undefined && (
1124 <Text wrap="wrap" dimColor>
1125 {t.pane.fileLabel} {entry.filePath}
1126 </Text>
1127 )}
1128 {entry.lines === undefined && entry.rule !== t.unavailable && (
1129 <Text wrap="wrap" dimColor>
1130 {t.pane.noPlace}
1131 </Text>
1132 )}
1133 {window.length > 0 && (
1134 <Box flexDirection="column" marginTop={1}>
1135 <Text wrap="wrap" dimColor>
1136 {legend}
1137 </Text>
1138 {window.map(line => (
1139 <Box key={`line-${key}-${line.n}`} flexDirection="column">
1140 <Text wrap={isOpen ? 'wrap' : 'truncate-end'} dimColor={!line.isHit} bold={line.isHit}>
1141 {line.isHit ? '› ' : ' '}
1142 {numberOf(line.n)}
1143 {tidy(isUnread ? (line.inFile ?? line.text) : line.text)}
1144 </Text>
1145 {line.isHit && !isUnread && line.inFile !== undefined && (
1146 <Text wrap={isOpen ? 'wrap' : 'truncate-end'} color="warning">
1147 {' '}
1148 {entry.file === undefined ? t.pane.inText : t.pane.inFile} {tidy(line.inFile).trimStart()}
1149 </Text>
1150 )}
1151 </Box>
1152 ))}
1153 </Box>
1154 )}
1155 {hash !== undefined && drawValue(hash)}
1156 {hash !== undefined && (
1157 <Box flexDirection="row" gap={1} marginTop={1}>
1158 {revealButton(hash, `reveal-${key}`)}
1159 {isOpen && ALLOWABLE.has(entry.decision) && !allowedBy.has(hash) && (
1160 <Button key={`allow-${key}`} label={t.pane.allowButton} onPress={() => allowHash(hash, entry.rule, entry.mask)} />
1161 )}
1162 </Box>
1163 )}
1164 </Box>
1165 )
1166 }
1167
1168 const tabs = (
1169 <Box flexDirection="row" gap={1} marginTop={1}>
1170 <Button
1171 key="tab-session"
1172 label={t.pane.tabSession}
1173 {...(activeTab === 'session' ? { variant: 'primary' as const } : {})}
1174 onPress={() => update($, tab, () => 'session' as const)}
1175 />
1176 <Button
1177 key="tab-history"
1178 label={t.pane.tabHistory}
1179 {...(activeTab === 'history' ? { variant: 'primary' as const } : {})}
1180 onPress={() => update($, tab, () => 'history' as const)}
1181 />
1182 </Box>
1183 )
1184 const drawPending = (now: Pending) => (
1185 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
1186 <Text wrap="wrap" bold color="warning">
1187 {t.pane.pendingTitle}
1188 </Text>
1189 <Text wrap="wrap" dimColor>
1190 {now.source}
1191 </Text>
1192 {now.items.map(item => {
1193 const place =
1194 item.line === undefined
1195 ? undefined
1196 : item.file !== undefined && item.isFileLine === true
1197 ? t.pane.at(item.file, item.line)
1198 : `${item.file === undefined ? '' : `${item.file} · `}${t.pane.textLine(item.line)}`
1199 const numberOf = (n: number) => (item.isNumbered === true ? '' : `${String(n).padStart(4)} `)
1200hooks/redact.ts 324 lines1// Pure helpers: reading gitleaks' report, masking and cutting secrets out of
2// text. Nothing here calls the engine.
3
4/** One finding of a gitleaks report, as the guard needs it. */
5export type Leak = {
6 rule: string
7 secret: string
8 startLine: number
9 endLine: number
10 /**
11 * gitleaks found it after decoding (base64, hex, percent): `secret` is the
12 * decoded value and is not in the text, so the lines are cut instead.
13 */
14 isEncoded: boolean
15}
16
17/**
18 * A leak to cut, the rule its placeholder names (the most specific one gitleaks
19 * matched the secret under) and the number the model reads it by.
20 */
21export type Cut = { leak: Leak; rule: string; number: number }
22
23/** A leak the model may not read yet, with its secret's hash. */
24export type Found = { leak: Leak; hash: string }
25
26/**
27 * One per secret: the same value found twice is one secret. Where gitleaks
28 * matched it under several rules, the specific one (`github-pat`) is kept
29 * over a generic one (`generic-api-key`).
30 */
31export function uniqueByHash<T extends { hash: string; leak?: Leak }>(found: readonly T[]): T[] {
32 const seen = new Set<string>()
33 const specificFirst = [...found].sort((a, b) => generality(a.leak) - generality(b.leak))
34
35 return specificFirst.filter(one => !seen.has(one.hash) && seen.add(one.hash) !== undefined)
36}
37
38function generality(leak: Leak | undefined): number {
39 return leak?.rule.startsWith('generic') ? 1 : 0
40}
41
42/** Reads `gitleaks ... --report-format json --report-path -` output. */
43export function parseReport(stdout: string): Leak[] {
44 const trimmed = stdout.trim()
45 if (trimmed === '') return []
46 const raw: unknown = JSON.parse(trimmed)
47 if (!Array.isArray(raw)) throw new Error('the report is not a list')
48
49 return raw.map(toLeak).filter(leak => leak.secret !== '')
50}
51
52function toLeak(item: unknown): Leak {
53 const r = (item ?? {}) as Record<string, unknown>
54 const secret = typeof r.Secret === 'string' && r.Secret !== '' ? r.Secret : String(r.Match ?? '')
55 const tags = Array.isArray(r.Tags) ? r.Tags : []
56 const startLine = Number(r.StartLine ?? 0)
57
58 return {
59 rule: String(r.RuleID ?? 'unknown'),
60 secret,
61 startLine,
62 endLine: Number(r.EndLine ?? startLine),
63 isEncoded: tags.some(tag => typeof tag === 'string' && tag.startsWith('decoded:')),
64 }
65}
66
67/** What the person sees of a secret: its first four characters at most, and its length. */
68export function mask(secret: string): string {
69 const head = secret.length >= 16 ? secret.slice(0, 4) : ''
70
71 return `${head}…[${secret.length}]`
72}
73
74export function placeholder(rule: string, number: number, isEncoded: boolean): string {
75 return isEncoded
76 ? `[SECRET:${rule}#${number}: encoded secret, line withheld]`
77 : `[SECRET:${rule}#${number}]`
78}
79
80/** What replaces a text that cannot be cut whole. */
81export const WHOLE = '[SECRET: text withheld whole]'
82
83/** A string to replace wherever it occurs, and what replaces it. */
84export type Needle = { needle: string; label: string }
85
86/**
87 * What to look for to cut the leaks found in `text`: a secret's value where
88 * it stands in the text, and for an encoded one the whole lines that carry
89 * it. Where several leaks claim one string, the specific rule over a
90 * generic one, the narrower span over a wider. Undefined when an encoded
91 * leak's lines are not in the text: then nothing can be cut, only all.
92 */
93export function needlesFor(text: string, cuts: readonly Cut[]): Needle[] | undefined {
94 const lines = text.split('\n').map(line => line.replace(/\r$/, ''))
95 const best = new Map<string, { label: string; rank: number }>()
96 const add = (needle: string, label: string, rank: number) => {
97 const have = best.get(needle)
98 if (have === undefined || rank < have.rank) best.set(needle, { label, rank })
99 }
100
101 for (const { leak, rule, number } of cuts) {
102 const rank = (rule.startsWith('generic') ? 1000 : 0) + (leak.endLine - leak.startLine)
103 if (text.includes(leak.secret)) add(leak.secret, placeholder(rule, number, false), rank)
104 if (!leak.isEncoded) continue
105
106 const isInside = leak.startLine >= 1 && leak.endLine >= leak.startLine && leak.endLine <= lines.length
107 if (!isInside) return undefined
108 // A line holding the decoded value as it is was matched as plain text,
109 // and is cut by that value above; the others carry it encoded.
110 const encoded = lines
111 .slice(leak.startLine - 1, leak.endLine)
112 .filter(line => !line.includes(leak.secret) && line.trim().length >= 8)
113 if (encoded.length === 0 && !text.includes(leak.secret)) return undefined
114 for (const line of encoded) add(line, placeholder(rule, number, true), rank)
115 }
116
117 return [...best]
118 .map(([needle, { label }]) => ({ needle, label }))
119 .sort((a, b) => b.needle.length - a.needle.length)
120}
121
122/** Replaces every needle in every string of a value, objects and arrays walked. */
123export function redactValue<T>(value: T, needles: readonly Needle[]): T {
124 if (typeof value === 'string') {
125 let out: string = value
126 for (const { needle, label } of needles) out = out.split(needle).join(label)
127
128 return out as T
129 }
130 if (Array.isArray(value)) return value.map(item => redactValue(item, needles)) as T
131 if (value !== null && typeof value === 'object') {
132 const out: Record<string, unknown> = {}
133 for (const [key, item] of Object.entries(value)) out[key] = redactValue(item, needles)
134
135 return out as T
136 }
137
138 return value
139}
140
141/**
142 * A value with the leaks found in `text` cut out of every string, or
143 * undefined when that cannot be done whole: a secret would be left in.
144 */
145export function redactRecord<T>(record: T, text: string, cuts: readonly Cut[]): T | undefined {
146 const needles = needlesFor(text, cuts)
147 if (needles === undefined) return undefined
148 const out = redactValue(record, needles)
149 const secrets = cuts.map(({ leak }) => leak.secret)
150 const isClean = everyString(out, one => secrets.every(secret => !one.includes(secret)))
151
152 return isClean ? out : undefined
153}
154
155/** Cuts every leak out of `text`; one that cannot be cut cuts the whole text. */
156export function redact(text: string, cuts: readonly Cut[]): string {
157 return cuts.length === 0 ? text : (redactRecord(text, text, cuts) ?? WHOLE)
158}
159
160function everyString(value: unknown, test: (text: string) => boolean): boolean {
161 if (typeof value === 'string') return test(value)
162 if (Array.isArray(value)) return value.every(item => everyString(item, test))
163 if (value !== null && typeof value === 'object') return Object.values(value).every(item => everyString(item, test))
164
165 return true
166}
167
168/**
169 * One line of an excerpt: `text` as the model read it, `inFile` as the text
170 * holds it with the value masked, when the two differ.
171 */
172export type ExcerptLine = { n: number; text: string; isHit: boolean; inFile?: string }
173
174/** Where a leak stands and the lines around it, never a value. */
175export type Excerpt = {
176 /** The file the lines come from, when the text says which. */
177 file?: string
178 /** The line in that file, or else in the text. */
179 line: number
180 isFileLine: boolean
181 /** The lines carry their own numbers (a Read's, a Grep's); else `n` numbers them. */
182 isNumbered: boolean
183 lines: ExcerptLine[]
184}
185
186// ` 6\tcode` or ` 6→code`: a Read of a file, a changed-file note.
187const NUMBERED = /^\s*(\d+)(?:\t|→)/
188// `src/app.ts:12:code`: a Grep with line numbers.
189const GREP = /^([^\s:]*[/.][^\s:]*):(\d+)[:-]/
190const EXCERPT_LINE = 240
191
192/**
193 * The lines around `hit` in `text`, each twice over: as the model read it,
194 * every leak shown by `labelOf` (its placeholder where it was cut, its mask
195 * where the model may read the value), and as the text holds it with every
196 * value masked. The pane never shows a value: a line one could still be read
197 * from is dropped to `[…]`.
198 */
199export function excerpt(
200 text: string,
201 leaks: readonly Leak[],
202 hit: Leak,
203 labelOf: (leak: Leak, isEncodedLine: boolean) => string,
204 where: { file?: string; isFileText?: boolean } = {},
205 around = 6,
206): Excerpt {
207 const lines = text.split('\n').map(line => line.replace(/\r$/, ''))
208 const start = Math.min(Math.max(1, hit.startLine), lines.length)
209 const end = Math.min(Math.max(start, hit.endLine), lines.length)
210 const from = Math.max(1, start - around)
211 const to = Math.min(lines.length, end + around)
212
213 const asRead: Needle[] = []
214 const asHeld: Needle[] = []
215 const pieces: string[] = []
216 for (const leak of leaks) {
217 const parts = [leak.secret, ...leak.secret.split('\n').filter(part => part.trim().length >= 8)]
218 for (const part of parts) {
219 asRead.push({ needle: part, label: labelOf(leak, false) })
220 asHeld.push({ needle: part, label: mask(leak.secret) })
221 pieces.push(part)
222 }
223 if (!leak.isEncoded) continue
224 for (const line of lines.slice(leak.startLine - 1, leak.endLine)) {
225 if (line.includes(leak.secret) || line.trim().length < 8) continue
226 asRead.push({ needle: line, label: labelOf(leak, true) })
227 asHeld.push({ needle: line, label: '[encoded secret]' })
228 }
229 }
230 const longestFirst = (a: Needle, b: Needle) => b.needle.length - a.needle.length
231 asRead.sort(longestFirst)
232 asHeld.sort(longestFirst)
233 const safe = (line: string) => {
234 const clipped = line.length > EXCERPT_LINE ? `${line.slice(0, EXCERPT_LINE - 1)}…` : line
235
236 return pieces.every(piece => !clipped.includes(piece)) ? clipped : '[…]'
237 }
238
239 const shown = lines.slice(from - 1, to).map((line, index) => {
240 const n = from + index
241 const read = safe(redactValue(line, asRead))
242 const held = safe(redactValue(line, asHeld))
243 const isHit = n >= start && n <= end
244
245 return held === read ? { n, text: read, isHit } : { n, text: read, isHit, inFile: held }
246 })
247
248 const first = lines[start - 1] ?? ''
249 const numbered = NUMBERED.exec(first)
250 if (numbered?.[1] !== undefined) {
251 return { file: where.file, line: Number(numbered[1]), isFileLine: true, isNumbered: true, lines: shown }
252 }
253 const grep = GREP.exec(first)
254 if (grep?.[1] !== undefined && grep[2] !== undefined) {
255 return { file: grep[1], line: Number(grep[2]), isFileLine: true, isNumbered: true, lines: shown }
256 }
257
258 return { file: where.file, line: start, isFileLine: where.isFileText === true, isNumbered: false, lines: shown }
259}
260
261// The guard's own marks in a text: a placeholder the model read, a mask the
262// person saw (`ghp_…[40]`, `…[12]`).
263const OWN_MARKS = /\[SECRET:[^\]\n]{1,160}\]|\S{0,4}…\[\d{1,4}\]/g
264
265/**
266 * The text with the guard's own placeholders and masks blanked to spaces of
267 * the same length, so a scan finds nothing in them ("SECRET:" read as a
268 * keyword) and every line and column stays where it was.
269 */
270export function withoutOwnMarks(text: string): string {
271 return text.replace(OWN_MARKS, mark => ' '.repeat(mark.length))
272}
273
274/** A content block of a stored row, as `session.append` hands it. */
275export type Block = { type: string; [field: string]: unknown }
276
277/**
278 * Runs `scrub` over every text the model reads in a row's blocks: text blocks
279 * and a tool_result's content, a string or text blocks. Undefined when
280 * nothing changed, so the row is passed on as it came.
281 */
282export async function mapTexts(
283 blocks: readonly Block[],
284 scrub: (text: string) => Promise<string>,
285): Promise<Block[] | undefined> {
286 let isChanged = false
287 const scrubOne = async (text: string) => {
288 const out = await scrub(text)
289 isChanged ||= out !== text
290
291 return out
292 }
293
294 const out: Block[] = []
295 for (const block of blocks) {
296 if (block.type === 'text' && typeof block.text === 'string') {
297 out.push({ ...block, text: await scrubOne(block.text) })
298 } else if (block.type === 'tool_result' && typeof block.content === 'string') {
299 out.push({ ...block, content: await scrubOne(block.content) })
300 } else if (block.type === 'tool_result' && Array.isArray(block.content)) {
301 const inner: unknown[] = []
302 for (const item of block.content as unknown[]) {
303 const part = (item ?? {}) as Block
304 inner.push(part.type === 'text' && typeof part.text === 'string' ? { ...part, text: await scrubOne(part.text) } : item)
305 }
306 out.push({ ...block, content: inner })
307 } else {
308 out.push(block)
309 }
310 }
311
312 return isChanged ? out : undefined
313}
314
315/** A SHA-256 prefix: how the guard tells secrets apart without keeping them. */
316export async function hashOf(text: string): Promise<string> {
317 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
318
319 return [...new Uint8Array(digest)]
320 .slice(0, 12)
321 .map(byte => byte.toString(16).padStart(2, '0'))
322 .join('')
323}
324hooks/rules.ts 162 lines1// Built by scripts/build_rules.py from rules/*.toml; edit those, then run it.
2// scripts/check_rules.py checks the rules against real gitleaks.
3
4/** Secrets after a keyword (EN/RU), in a URL, after Bearer, and sk-proj- keys. */
5export const KEYWORD_RULES = [
6 "[[rules]]",
7 "id = \"password-after-keyword\"",
8 "description = \"A password, secret or token written after a keyword, in English or Russian\"",
9 "regex = '''(?i)(?:пароль|парол[яюеи]|password|passwd|passphrase|pwd|секрет|secret|токен|token|ключ доступа|access key)[\\p{L}_-]*(?:(?:[ \\t]+\\p{L}{1,15}){1,3}(?:[ \\t]*[:=][ \\t]*|[ \\t]+(?:—|–|-|is|это|равен)[ \\t]+)|(?:[ \\t]*[:=][ \\t]*|[ \\t]+(?:(?:—|–|-|is|это|равен)[ \\t]+)?))[\"'`]?(?:[\\p{L}_]{1,30}=)?([^\\s\"'`:=][^\\s\"'`]{5,})'''",
10 "secretGroup = 1",
11 "keywords = [\"пароль\", \"пароля\", \"паролю\", \"пароле\", \"пароли\", \"password\", \"passwd\", \"passphrase\", \"pwd\", \"секрет\", \"secret\", \"токен\", \"token\", \"ключ доступа\", \"access key\"]",
12 "[[rules.allowlists]]",
13 "regexTarget = \"secret\"",
14 "regexes = [",
15 " '''^[*_~]*[\\p{L}_-]+[*_~]*[.,;:!?)]*$''',",
16 " '''[<>]''',",
17 " '''(?i)^(true|false|null|none|nil|undefined)\\W*$''',",
18 " '''^[0-9.,:/_-]+$''',",
19 " '''^(?:~|\\.{1,2})?/''',",
20 " '''^\\$[A-Za-z_{(]''',",
21 " '''\\{\\{|\\$\\{|%\\(''',",
22 " '''^<[^>]*>$''',",
23 " '''\\w\\(''',",
24 " '''^\\[|\\]$''',",
25 " '''^\\*+$''',",
26 " '''(?i)^(changeme|example|placeholder|redacted|xxx+|your[_-]?\\w*)$''',",
27 " '''(?i)^(?:password|passwd|passphrase|pwd|secret|token|пароль|парол\\p{L}*|секрет\\p{L}*|токен\\p{L}*)s?[.,;:!?)]*$''',",
28 " '''^[\\p{L}_]+[=:](?:[0-9.]{1,8}|[\\p{L}_-]{1,24})[.,;)]*$''',",
29 " '''^[a-z_.-]+(?:/[a-z_.-]+)+$''',",
30 " '''^[A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)+$''',",
31 " '''^[(\\[{]''',",
32 " '''…''',",
33 "]",
34 "[[rules.allowlists]]",
35 "regexTarget = \"match\"",
36 "regexes = ['''(?i)secret:[\\w-]+#\\d''']",
37 "",
38 "[[rules]]",
39 "id = \"password-assignment\"",
40 "description = \"A password in a config assignment (TOML, INI, .env, JSON, YAML): a value after a key that ends with password, passwd, pwd, passphrase, secret or _pass\"",
41 "regex = '''(?im)(?:(?:^|[^\\w])[\"']?[\\w.-]*?(?:password|passwd|passphrase|pwd|secret|[_.-]pass|(?-i:Pass))[\"']?[ \\t]*(?:=|:=)[ \\t]*(?:\"([^\"\\n]{4,200})\"|'([^'\\n]{4,200})\\'|([^\\s\"'`#,;}\\]]{4,200}))|(?:^[ \\t]*(?:\\d+(?:\\t|→|:|-)[ \\t]*|[^\\s:]+:\\d+[:-][ \\t]*)?(?:-[ \\t]+)?[\"']?[\\w.-]*?(?:password|passwd|passphrase|pwd|secret|[_.-]pass|(?-i:Pass))[\"']?|\"[\\w.-]*?(?:password|passwd|passphrase|pwd|secret|[_.-]pass|(?-i:Pass))\")[ \\t]*:[ \\t]*(?:\"([^\"\\n]{4,200})\"|'([^'\\n]{4,200})\\'|([^\\s\"'`#,;}\\]]{4,200})(?:[ \\t]+#.*)?[ \\t]*$))'''",
42 "keywords = [\"password\", \"passwd\", \"passphrase\", \"pwd\", \"secret\", \"pass\"]",
43 "[[rules.allowlists]]",
44 "regexTarget = \"secret\"",
45 "regexes = [",
46 " '''^\\$|\\$\\{|\\{\\{|%\\(|\\$\\(''',",
47 " '''^<[^>]*>$''',",
48 " '''\\w\\(''',",
49 " '''SECRET:|…''',",
50 " '''^(?:~|\\.{1,2})?/''',",
51 " '''^[a-z_.-]+(?:/[a-z_.-]+)+$''',",
52 " '''^[A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)+$''',",
53 " '''^\\*+$''',",
54 " '''(?i)^(?:changeme|change_me|example|placeholder|redacted|xxx+|your[_-]?\\w*|todo|none|null|nil|true|false|undefined|required|optional|string|str|number|int|integer|bool|boolean|bytes|text|varchar|any|object|secret|secrets|password|passwd|pwd|token|hidden|masked|empty|unset|env)$''', '''^(?:\\*\\*|__|~~)[\\p{L}\\p{N} _-]+(?:\\*\\*|__|~~)[.,;!?)]*$''',",
55 "]",
56 "[[rules.allowlists]]",
57 "regexTarget = \"match\"",
58 "regexes = ['''(?i)secret:[\\w-]+#\\d''']",
59 "",
60 "[[rules]]",
61 "id = \"credentials-pair\"",
62 "description = \"A login:password pair after a word about access or credentials, in English or Russian\"",
63 "regex = '''(?i)(?:доступ|логин|креды|кред|учётк|учетк|учётн|учетн|аккаунт|вход|access|login|creds|credentials|account|auth)[\\p{L}_-]*(?:[ \\t]+[\\p{L}\\p{N}_.-]{1,20}){0,4}?[ \\t]*[:=—–-]?[ \\t]+[\"'`]?[\\p{L}\\p{N}_.@+-]{2,64}:([^\\s\"'`@]{6,})'''",
64 "secretGroup = 1",
65 "keywords = [\"доступ\", \"логин\", \"креды\", \"кред\", \"учётк\", \"учетк\", \"учётн\", \"учетн\", \"аккаунт\", \"вход\", \"access\", \"login\", \"creds\", \"credentials\", \"account\", \"auth\"]",
66 "[[rules.allowlists]]",
67 "regexTarget = \"secret\"",
68 "regexes = [",
69 " '''^[*_~]*[\\p{L}_-]+[*_~]*[.,;:!?)]*$''',",
70 " '''^[0-9.,:/_-]+$''',",
71 " '''^[0-9]+[/?#]''',",
72 " '''^//''',",
73 " '''::''',",
74 " '''^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2})+$''',",
75 " '''[<>]''',",
76 " '''^\\$|\\$\\{|\\{\\{''',",
77 " '''(?i)(?:password|passwd|passphrase|pwd|secret|token|пароль|парол|секрет|токен)''',",
78 "]",
79 "",
80 "[[rules]]",
81 "id = \"url-credentials\"",
82 "description = \"A password in a URL's user info (scheme://user:password@host)\"",
83 "regex = '''\\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@\"'`]+:([^\\s:/@\"'`]{3,})@[^\\s/@\"'`]+'''",
84 "secretGroup = 1",
85 "keywords = [\"://\"]",
86 "[[rules.allowlists]]",
87 "regexTarget = \"secret\"",
88 "regexes = ['''^\\$''', '''^\\{''', '''^<''', '''^\\*+$''', '''(?i)^(password|pass|secret|changeme|xxx+)$''']",
89 "",
90 "[[rules]]",
91 "id = \"cli-password-argument\"",
92 "description = \"A password given on a command line: mysql -pPASS, sshpass -p PASS, curl -u user:PASS\"",
93 "regex = '''(?i)(?:\\b(?:mysql|mysqldump|mysqladmin|mariadb|mariadb-dump)\\b[^\\n|;&]*?\\s-p([^\\s\"'`-][^\\s\"'`]{3,})|\\bsshpass\\b[^\\n|;&]*?\\s-p[ \\t]*[\"']?([^\\s\"'`-][^\\s\"'`]{3,})|\\b(?:curl|http|https)\\b[^\\n|;&]*?\\s(?:-u|--user|--proxy-user)[ \\t=]+[\"']?[^\\s:\"'`]+:([^\\s\"'`@]{4,}))'''",
94 "keywords = [\"mysql\", \"mariadb\", \"sshpass\", \"curl\", \"http\"]",
95 "[[rules.allowlists]]",
96 "regexTarget = \"secret\"",
97 "regexes = ['''^\\$''', '''\\$\\{''', '''^\\{\\{''', '''^<[^>]*>$''', '''^\\*+$''', '''(?i)^(password|pass|passwd|changeme|xxx+)[.,;:)\\\\]*$''']",
98 "",
99 "[[rules]]",
100 "id = \"basic-auth\"",
101 "description = \"Credentials in an Authorization: Basic header\"",
102 "regex = '''(?i)\\bauthorization:[ \\t]*basic[ \\t]+([a-z0-9+/]{12,}={0,2})'''",
103 "secretGroup = 1",
104 "keywords = [\"basic\"]",
105 "",
106 "[[rules]]",
107 "id = \"bearer-token\"",
108 "description = \"A bearer token in an Authorization header\"",
109 "regex = '''(?i)\\bbearer[ \\t]+([a-z0-9._~+/-]{16,}=*)'''",
110 "secretGroup = 1",
111 "entropy = 3.0",
112 "keywords = [\"bearer\"]",
113 "",
114 "[[rules]]",
115 "id = \"openai-project-key\"",
116 "description = \"An OpenAI project, service-account or admin key\"",
117 "regex = '''\\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,})'''",
118 "secretGroup = 1",
119 "keywords = [\"sk-proj-\", \"sk-svcacct-\", \"sk-admin-\"]",
120].join('\n')
121
122/** A long random-looking token with no known shape, by its Shannon entropy. */
123export const ENTROPY_RULE = [
124 "[[rules]]",
125 "id = \"high-entropy-token\"",
126 "description = \"A long random-looking token with no known shape\"",
127 "regex = '''(?:^|[\\s\"'`=:(,\\[{])([A-Za-z0-9_+/~.-]{20,128}={0,2})(?:$|[\\s\"'`;,)\\]}&])'''",
128 "secretGroup = 1",
129 "entropy = 4.0",
130 "[[rules.allowlists]]",
131 "regexTarget = \"secret\"",
132 "regexes = [",
133 " '''^[^A-Z]*$''',",
134 " '''^[^a-z]*$''',",
135 " '''^[^0-9]*$''',",
136 " '''\\.''',",
137 " '''^[0-9a-fA-F._-]+$''',",
138 " '''^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-''',",
139 " '''(?i)^(sha1|sha256|sha384|sha512|md5)[-:]''',",
140 " '''^/*[\\w.-]*(/[\\w.@+-]+){2,}/?$''',",
141 " '''^//''',",
142 " '''ABCDEFGHIJ|abcdefghij|0123456789''',",
143 " '''^[\\w-]+(\\.[\\w-]+){2,}$''',",
144 " '''(?i)^(https?|file|git|ssh)[:/]''',",
145 " '''^(?:[a-z]+(?:[A-Z][a-z]+)*|(?:[A-Z][a-z]+)+|[A-Z]+|[0-9]+|[a-z]{1,4}[0-9]{1,4}|[0-9]{1,4}[a-z]{1,4})(?:[-_/+](?:[a-z]+(?:[A-Z][a-z]+)*|(?:[A-Z][a-z]+)+|[A-Z]+|[0-9]+|[a-z]{1,4}[0-9]{1,4}|[0-9]{1,4}[a-z]{1,4}))+$''',",
146 "]",
147].join('\n')
148
149export type RuleSet = { keywords: boolean; entropy: boolean }
150
151/**
152 * The gitleaks configuration the guard runs with: the chosen rules over the
153 * project's own `.gitleaks.toml` (which may itself extend the default set),
154 * over gitleaks' default set otherwise.
155 */
156export function configWith(base: string | undefined, rules: RuleSet): string {
157 const extend = base === undefined ? 'useDefault = true' : `path = '${base.replace(/'/g, '')}'`
158 const chosen = [rules.keywords ? KEYWORD_RULES : '', rules.entropy ? ENTROPY_RULE : ''].filter(Boolean)
159
160 return `[extend]\n${extend}\n\n${chosen.join('\n\n')}\n`
161}
162hooks/words.ts 124 lines1// Random-looking words in the person's own prompt: a password typed or pasted
2// with nothing around it to say so ("84D83c3po!!!", "3sp7qaA").
3//
4// Shannon entropy cannot tell a short password from a word: a string of n
5// characters has at most log2(n) bits per character, so "planets" scores as
6// high as "3sp7qaA". What does tell them apart is how often the kind of
7// character changes (lower, upper, digit, symbol) along the word, counting a
8// Capital followed by lowercase as one run, as identifiers write words.
9//
10// Measured (scripts/words_experiment.py, on random passwords and on 593 past
11// prompts up to 2000 characters): 73% of passwords of 8 characters or more
12// caught, a dialog on 0.2%
13// of prompts. Longer prompts are pastes (logs, configs, CSS), where the same
14// measure raised a dialog on one in six: gitleaks' rules cover those, as they
15// cover tool outputs, where this measure is far too noisy to run at all.
16
17import type { Leak } from './redact'
18
19export const RULE = 'random-word'
20/** The longest prompt the measure runs on. */
21export const MAX_PROMPT = 2000
22const THRESHOLD = 0.3
23// Shorter than this a password guards little anyway, and words that short
24// read as random far more often.
25export const MIN_LENGTH = 8
26
27const TOKEN = /[^\s"'`()[\]{}<>,;|]+/g
28// Quotes, markdown marks and the commas and colons of prose are no part of a
29// word. A full stop at its end is left out when judging it (a sentence's end),
30// and kept out of what is cut.
31const EDGE = /^[«»"'’“”‘,;:*#>]+|[«»"'’“”‘,;:*#>]+$/g
32const TRAIL = /[.]+$/
33// Every mark that is neither a letter nor a digit joins parts: an identifier
34// (ACME_DB_PASS), a pattern (^NAME=), a path. A password keeps letters and
35// digits mixed inside one part (73Kd91a4qx), which is what is judged.
36const SEPARATORS = /[^\p{L}\p{Nd}]+/u
37const PURE = /^(?:\p{L}+|\p{Nd}+)$/u
38// In a word of several parts ("python3-venv", "ab12.com") a part of letters
39// then digits, or digits then letters, is a name too.
40const NAME = /^(?:\p{L}+\p{Nd}+|\p{Nd}+\p{L}+)$/u
41const DATE = /^\d{4}-\d{2}-\d{2}/
42const SKIP = /:\/\/|SECRET:|…/
43
44type Kind = 'lower' | 'upper' | 'digit' | 'symbol'
45
46function kindOf(ch: string): Kind {
47 if (/\p{Ll}/u.test(ch)) return 'lower'
48 if (/\p{Lu}/u.test(ch)) return 'upper'
49 if (/\p{Nd}/u.test(ch)) return 'digit'
50
51 return 'symbol'
52}
53
54/**
55 * How random a word reads, 0 to 1: kind changes per character. 0 for a word
56 * with no letters, letters and nothing but letters, or fewer than three runs.
57 */
58export function switches(word: string): number {
59 const chars = [...word]
60 const runs: { kind: Kind | 'word'; size: number }[] = []
61 for (const ch of chars) {
62 const kind = kindOf(ch)
63 const last = runs.at(-1)
64 if (last?.kind === kind) last.size += 1
65 else runs.push({ kind, size: 1 })
66 }
67 const letters = runs.filter(run => run.kind === 'lower' || run.kind === 'upper').length
68 const hasDigit = runs.some(run => run.kind === 'digit')
69 const hasSymbol = runs.some(run => run.kind === 'symbol')
70 if (letters === 0 || (!hasDigit && !hasSymbol)) return 0
71
72 // "Encoder": a single capital and the lowercase after it are one word.
73 const merged: (Kind | 'word')[] = []
74 runs.forEach((run, index) => {
75 const before = runs[index - 1]
76 if (run.kind === 'lower' && before?.kind === 'upper' && before.size === 1 && merged.at(-1) === 'upper') {
77 merged[merged.length - 1] = 'word'
78 } else {
79 merged.push(run.kind)
80 }
81 })
82
83 // Two runs ("sha256", "base64") is how identifiers are named; passwords switch more.
84 return merged.length < 3 ? 0 : merged.length / chars.length
85}
86
87/** A word as it is cut: quotes and marks around it, and a full stop after it, left off. */
88export function bare(raw: string): string {
89 return raw.replace(EDGE, '').replace(TRAIL, '')
90}
91
92/**
93 * A word that reads as random: not a flag, date, URL, placeholder, mask,
94 * identifier of plain parts, or code of digits with a letter or two.
95 */
96export function isRandomWord(raw: string): boolean {
97 const word = bare(raw)
98 const chars = [...word]
99 if (chars.length < MIN_LENGTH || chars.length > 64 || word.startsWith('-') || DATE.test(word) || SKIP.test(word)) return false
100 const parts = word.split(SEPARATORS).filter(part => part !== '')
101 const impure = parts.filter(part => !PURE.test(part) && !(parts.length > 1 && NAME.test(part)))
102 if (impure.length === 0 || Math.max(...impure.map(part => [...part].length)) < 4) return false
103 const digits = chars.filter(ch => /\p{Nd}/u.test(ch)).length
104 const letters = chars.filter(ch => /\p{L}/u.test(ch)).length
105 if (digits >= 0.6 * chars.length && letters <= 2) return false
106
107 return switches(word) >= THRESHOLD
108}
109
110/** The random-looking words of a text, as leaks the guard handles like gitleaks' own. */
111export function randomWords(text: string): Leak[] {
112 if (text.length > MAX_PROMPT) return []
113 const leaks: Leak[] = []
114 text.split('\n').forEach((line, index) => {
115 for (const match of line.matchAll(TOKEN)) {
116 if (isRandomWord(match[0])) {
117 leaks.push({ rule: RULE, secret: bare(match[0]), startLine: index + 1, endLine: index + 1, isEncoded: false })
118 }
119 }
120 })
121
122 return leaks
123}
124hooks/texts.ts 479 lines1// Every word the guard shows. What the model reads is English whatever the
2// language; what the person reads follows the `language` option. Pure: no
3// engine calls.
4
5import type { Decision } from '../types'
6import { mask, uniqueByHash } from './redact'
7import type { Found } from './redact'
8
9export type Lang = 'en' | 'ru'
10
11// --- what the model reads ----------------------------------------------------
12
13/** The section the model reads in its system prompt while the guard is on. */
14export const NOTE = [
15 'secret-guard is active: a scanner (gitleaks) checks tool outputs, prompts and attachments before you read them.',
16 'Secrets it finds are replaced with placeholders like [SECRET:github-pat#1], or a whole tool output is withheld by the user.',
17 'Treat placeholders as opaque. Do not try to recover a withheld value: no re-reading, encoding, splitting or printing parts of it.',
18 'If a task needs a secret, use it indirectly (an environment variable, a file a command reads) or ask the user.',
19].join('\n')
20
21/** What the model reads in place of a tool output the person withheld. */
22export function hiddenNote(tool: string, found: readonly Found[]): string {
23 const rules = [...new Set(found.map(one => one.leak.rule))].join(', ')
24
25 return [
26 `secret-guard: the user withheld this ${tool} output from you: it contains secrets (${rules}).`,
27 'Do not try to obtain these values another way. If the task needs one, ask the user.',
28 ].join(' ')
29}
30
31/** What the model reads in place of a text the scanner could not check. */
32export function withheld(reason: string): string {
33 return `[secret-guard: text withheld, it could not be checked for secrets (${reason})]`
34}
35
36/**
37 * What the model reads in place of a text withheld because gitleaks is not
38 * installed: how to install it, so the agent can, and then try again.
39 */
40export const MISSING_NOTE = [
41 '[secret-guard: text withheld. gitleaks, the secret scanner, is not installed, so nothing can be checked.',
42 'Install it, then run this step again:',
43 'macOS `brew install gitleaks`;',
44 'Linux a release binary from https://github.com/gitleaks/gitleaks/releases into ~/.local/bin,',
45 'or `go install github.com/zricethezav/gitleaks/v8@latest`.]',
46].join(' ')
47
48export const TOOL_FAILED = 'secret-guard: the output could not be checked for secrets and was withheld.'
49export const MENTION_SKIPPED = 'secret-guard: the file holds secrets; the user chose not to attach it'
50export const MENTION_FAILED = 'secret-guard: the file could not be checked for secrets'
51
52// --- what the person reads ---------------------------------------------------
53
54export type Texts = {
55 header: string
56 toolOptions: { redact: string; hide: string; pass: string; allow: string }
57 promptOptions: { redact: string; send: string; cancel: string; allow: string }
58 mentionOptions: { redact: string; skip: string; pass: string; allow: string }
59 failureOptions: { hide: string; pass: string }
60 missingOptions: { install: string; hide: string; pass: string }
61 missingQuestion: (source: string) => string
62 aborted: string
63 installing: string
64 installed: string
65 installFailed: (line: string) => string
66 noBrew: string
67 missingToast: string
68 decisions: Record<Decision, string>
69 doors: Record<string, string>
70 toolQuestion: (source: string, found: readonly Found[]) => string
71 promptQuestion: (found: readonly Found[]) => string
72 mentionQuestion: (source: string, found: readonly Found[]) => string
73 failureQuestion: (source: string, reason: string) => string
74 prompt: string
75 shellCommand: string
76 subagentPrefix: string
77 subagentSuffix: string
78 attachment: (type: string, file?: string) => string
79 context: (name: string) => string
80 systemPrompt: (section: string) => string
81 promptDropped: string
82 promptDroppedFailure: (reason: string) => string
83 promptFailed: string
84 mentionSkipped: (source: string) => string
85 autoCut: (rule: string, source: string) => string
86 withheldToast: (source: string) => string
87 missing: string
88 notFound: string
89 exited: (code: number, line: string) => string
90 unreadable: (message: string) => string
91 unavailable: string
92 status: (hidden: number) => string
93 commandDescription: string
94 paneOpened: string
95 pane: {
96 scanner: (detail: string) => string
97 intro: string
98 findings: (count: number) => string
99 none: string
100 at: (file: string, line: number) => string
101 textLine: (line: number) => string
102 legendRead: string
103 legendSaw: string
104 legendNothing: string
105 inFile: string
106 inText: string
107 sourceLabel: string
108 fileLabel: string
109 noPlace: string
110 showValue: string
111 hideValue: string
112 valueLabel: string
113 valueWarning: string
114 valueGone: string
115 clear: string
116 secrets: (count: number) => string
117 secretsHint: string
118 logHint: string
119 statusCut: string
120 statusNew: string
121 statusPassed: string
122 statusAllowed: string
123 seen: (count: number) => string
124 last: (time: string, source: string) => string
125 forgetAll: string
126 cutButton: string
127 tabSession: string
128 pendingTitle: string
129 pendingLegend: string
130 tabHistory: string
131 historyHint: string
132 historyNone: string
133 clearHistory: string
134 dropSession: string
135 sessionHeader: (date: string, project: string, session: string, count: number, isThis: boolean) => string
136 allowButton: string
137 installButton: string
138 forgetButton: string
139 }
140}
141
142// What the engine's attachment kinds are, in the person's words.
143const ATTACHMENTS_EN: Record<string, string> = {
144 edited_text_file: 'changed file',
145 file: 'attached file',
146 nested_memory: 'CLAUDE.md',
147 queued_command: 'queued prompt',
148}
149
150const ATTACHMENTS_RU: Record<string, string> = {
151 edited_text_file: 'изменённый файл',
152 file: 'приложенный файл',
153 nested_memory: 'CLAUDE.md',
154 queued_command: 'промпт из очереди',
155}
156
157const EN: Texts = {
158 header: 'secret-guard',
159 toolOptions: {
160 redact: 'Cut the secrets',
161 hide: 'Hide the whole output',
162 pass: 'Let the model see it',
163 allow: 'Not a secret, allow',
164 },
165 promptOptions: {
166 redact: 'Cut the secrets',
167 send: 'Send as is',
168 cancel: 'Do not send',
169 allow: 'Not a secret, allow',
170 },
171 mentionOptions: {
172 redact: 'Cut the secrets',
173 skip: 'Do not attach',
174 pass: 'Attach as is',
175 allow: 'Not a secret, allow',
176 },
177 failureOptions: { hide: 'Hide from the model', pass: 'Pass it unchecked' },
178 missingOptions: { install: 'Install gitleaks and check', hide: 'Hide from the model', pass: 'Pass it unchecked' },
179 missingQuestion: source => `gitleaks is not installed, so ${clip(source, 90)} cannot be checked. Install it now (brew install gitleaks)?`,
180 aborted: 'the check was interrupted',
181 installing: 'secret-guard: installing gitleaks (brew install gitleaks)…',
182 installed: 'secret-guard: gitleaks installed, checks are on',
183 installFailed: line => `gitleaks could not be installed: ${line}`,
184 noBrew: 'Homebrew not found: install gitleaks by hand, https://github.com/gitleaks/gitleaks#installing',
185 missingToast: 'secret-guard: gitleaks not found, nothing is checked. /secrets → install gitleaks',
186 decisions: {
187 redacted: 'cut on your choice',
188 recut: 'cut again: this value was cut before, so no question',
189 hidden: 'the whole output hidden from the model',
190 passed: 'passed on your choice: the model saw the value',
191 allowlisted: 'marked not a secret: the model saw the value',
192 dropped: 'not sent to the model',
193 'auto-redacted': 'cut without asking (text the engine adds on its own)',
194 withheld: 'withheld whole: the scanner could not check it',
195 },
196 doors: {
197 prompt: 'prompt',
198 shellCommand: 'your ! command',
199 command: 'command output',
200 'tool-result': 'tool result',
201 'tool-message': 'tool message',
202 delivery: 'incoming message',
203 attachment: 'attachment',
204 'hook-context': 'hook context',
205 note: 'plugin note',
206 },
207 toolQuestion: (source, found) => `${clip(source, 90)}: secrets found (${describe(found, 'en')}). What should the model see?`,
208 promptQuestion: found => `Your prompt holds secrets (${describe(found, 'en')}). What should be sent?`,
209 mentionQuestion: (source, found) => `${source} holds secrets (${describe(found, 'en')}). What should be attached?`,
210 failureQuestion: (source, reason) => `secret-guard could not check ${source} (${reason}). Pass it to the model unchecked?`,
211 prompt: 'prompt',
212 shellCommand: 'your ! command',
213 subagentPrefix: 'subagent, ',
214 subagentSuffix: ' (subagent)',
215 attachment: (type, file) => `${ATTACHMENTS_EN[type] ?? `system note (${type})`}${file === undefined ? '' : ` ${file}`}`,
216 context: name => `context ${name}`,
217 systemPrompt: section => `system prompt (${section})`,
218 promptDropped: 'secret-guard: the prompt was not sent, it holds a secret. Its text is back in the input box.',
219 promptDroppedFailure: reason => `secret-guard: the prompt was not sent, it could not be checked (${reason}).`,
220 promptFailed: 'secret-guard: the prompt could not be checked for secrets and was not sent.',
221 mentionSkipped: source => `secret-guard: ${source} was not attached`,
222 autoCut: (rule, source) => `secret-guard: cut ${rule} (${source})`,
223 withheldToast: source => `secret-guard: ${source} withheld, the scanner is unavailable`,
224 missing: 'gitleaks not found: brew install gitleaks',
225 notFound: 'gitleaks not found (brew install gitleaks)',
226 exited: (code, line) => `gitleaks exited with ${code}: ${line}`,
227 unreadable: message => `could not read the gitleaks report: ${message}`,
228 unavailable: 'scanner unavailable',
229 status: hidden => `secret-guard: ${hidden} hidden · /secrets`,
230 commandDescription: 'secret-guard: secrets found, decisions and the allowlist',
231 paneOpened: 'secret-guard pane opened.',
232 pane: {
233 scanner: detail => `Scanner: ${detail}`,
234 intro: 'Secrets caught on their way to the model. Nothing in this pane is sent to it. Press a log entry to open it.',
235 findings: count => `Log (${count})`,
236 none: 'Nothing found yet.',
237 at: (file, line) => `${file}:${line}`,
238 textLine: line => `line ${line} of the text`,
239 legendRead: 'As the model read it (› the line with the secret):',
240 legendSaw: 'The model saw the value; here it is masked (› the line with the secret):',
241 legendNothing: 'The model read none of this; the text was, values masked (› the line with the secret):',
242 inFile: 'in the file:',
243 inText: 'in the text:',
244 sourceLabel: 'Source:',
245 fileLabel: 'File:',
246 noPlace: 'Recorded by an earlier version: no file or lines kept.',
247 showValue: 'show the value',
248 hideValue: 'hide the value',
249 valueLabel: 'Value:',
250 valueWarning: 'Only you see this pane; the model does not. It hides again in 30 s. Do not paste a screenshot of it into the chat: images reach the model and are not checked.',
251 valueGone: 'value not kept (the mod reloaded)',
252 clear: 'clear the journal',
253 secrets: count => `Secrets this session (${count})`,
254 secretsHint: 'Each value met, once. Forget it and you are asked again the next time it appears.',
255 logHint: 'What happened, newest first. Clearing it forgets nothing: known secrets stay known.',
256 statusCut: 'cut without a question: the model never sees it',
257 statusNew: 'not cut yet: you are asked when it appears',
258 statusPassed: 'passed by you: the model sees it',
259 statusAllowed: 'marked not a secret: the model sees it',
260 seen: count => `seen ${count} ${count === 1 ? 'time' : 'times'}`,
261 last: (time, source) => `last ${time}, ${source}`,
262 forgetAll: 'forget all',
263 cutButton: 'cut again',
264 tabSession: 'This session',
265 pendingTitle: 'Waiting for your answer in the dialog',
266 pendingLegend: 'The model has read none of this yet; the text, values masked (› the line with the secret):',
267 tabHistory: 'All history',
268 historyHint: 'Every session on this machine, newest first, up to 400 events. Kept on disk: masks and the lines around, never a value or a hash.',
269 historyNone: 'No history yet.',
270 clearHistory: 'clear all history',
271 dropSession: 'delete',
272 sessionHeader: (date, project, session, count, isThis) =>
273 `${date} · ${project} · ${session}${isThis ? ' (this session)' : ''} · ${count} ${count === 1 ? 'event' : 'events'}`,
274 allowButton: 'allow from now on',
275 installButton: 'install gitleaks',
276 forgetButton: 'forget',
277 },
278}
279
280const RU: Texts = {
281 header: 'secret-guard',
282 toolOptions: {
283 redact: 'Вырезать секреты',
284 hide: 'Скрыть весь вывод',
285 pass: 'Пропустить к модели',
286 allow: 'Не секрет, пропускать',
287 },
288 promptOptions: {
289 redact: 'Вырезать секреты',
290 send: 'Отправить как есть',
291 cancel: 'Не отправлять',
292 allow: 'Не секрет, пропускать',
293 },
294 mentionOptions: {
295 redact: 'Вырезать секреты',
296 skip: 'Не прикладывать файл',
297 pass: 'Приложить как есть',
298 allow: 'Не секрет, пропускать',
299 },
300 failureOptions: { hide: 'Скрыть от модели', pass: 'Пропустить без проверки' },
301 missingOptions: { install: 'Установить gitleaks и проверить', hide: 'Скрыть от модели', pass: 'Пропустить без проверки' },
302 missingQuestion: source => `gitleaks не установлен, ${clip(source, 90)} нечем проверить. Установить сейчас (brew install gitleaks)?`,
303 aborted: 'проверка прервана',
304 installing: 'secret-guard: устанавливаю gitleaks (brew install gitleaks)…',
305 installed: 'secret-guard: gitleaks установлен, проверка включена',
306 installFailed: line => `не удалось установить gitleaks: ${line}`,
307 noBrew: 'Homebrew не найден: установите gitleaks вручную, https://github.com/gitleaks/gitleaks#installing',
308 missingToast: 'secret-guard: gitleaks не найден, проверка не работает. /secrets → установить gitleaks',
309 decisions: {
310 redacted: 'вырезан по вашему решению',
311 recut: 'вырезан снова: это значение уже вырезалось, поэтому без вопроса',
312 hidden: 'весь вывод скрыт от модели',
313 passed: 'пропущен по вашему решению: модель видела значение',
314 allowlisted: 'отмечен «не секрет»: модель видела значение',
315 dropped: 'не отправлен модели',
316 'auto-redacted': 'вырезан без вопроса (текст, который движок добавляет сам)',
317 withheld: 'скрыт целиком: сканер не смог проверить',
318 },
319 doors: {
320 prompt: 'промпт',
321 shellCommand: 'ваша команда через !',
322 command: 'вывод команды',
323 'tool-result': 'результат инструмента',
324 'tool-message': 'сообщение инструмента',
325 delivery: 'входящее сообщение',
326 attachment: 'вложение',
327 'hook-context': 'контекст хука',
328 note: 'запись плагина',
329 },
330 toolQuestion: (source, found) => `${clip(source, 90)}: найдены секреты (${describe(found, 'ru')}). Что отдать модели?`,
331 promptQuestion: found => `В вашем промпте найдены секреты (${describe(found, 'ru')}). Что отправить модели?`,
332 mentionQuestion: (source, found) => `В файле ${source} найдены секреты (${describe(found, 'ru')}). Что приложить к промпту?`,
333 failureQuestion: (source, reason) => `secret-guard не смог проверить ${source} (${reason}). Отдать модели без проверки?`,
334 prompt: 'промпт',
335 shellCommand: 'ваша команда через !',
336 subagentPrefix: 'субагент, ',
337 subagentSuffix: ' (субагент)',
338 attachment: (type, file) => `${ATTACHMENTS_RU[type] ?? `системная заметка (${type})`}${file === undefined ? '' : ` ${file}`}`,
339 context: name => `контекст ${name}`,
340 systemPrompt: section => `системный промпт (${section})`,
341 promptDropped: 'secret-guard: промпт не отправлен, в нём секрет. Текст возвращён в поле ввода.',
342 promptDroppedFailure: reason => `secret-guard: промпт не отправлен, проверка не удалась (${reason}).`,
343 promptFailed: 'secret-guard: проверить промпт на секреты не удалось, промпт не отправлен.',
344 mentionSkipped: source => `secret-guard: ${source} не приложен`,
345 autoCut: (rule, source) => `secret-guard: вырезан ${rule} (${source})`,
346 withheldToast: source => `secret-guard: ${source} скрыт, сканер недоступен`,
347 missing: 'gitleaks не найден: brew install gitleaks',
348 notFound: 'gitleaks не найден (brew install gitleaks)',
349 exited: (code, line) => `gitleaks вернул код ${code}: ${line}`,
350 unreadable: message => `не удалось разобрать отчёт gitleaks: ${message}`,
351 unavailable: 'сканер недоступен',
352 status: hidden => `secret-guard: скрыто ${hidden} · /secrets`,
353 commandDescription: 'secret-guard: найденные секреты, решения и allowlist',
354 paneOpened: 'Панель secret-guard открыта.',
355 pane: {
356 scanner: detail => `Сканер: ${detail}`,
357 intro: 'Секреты, перехваченные по пути к модели. Ничего из этой панели модели не отправляется. Нажмите на запись журнала, чтобы раскрыть её.',
358 findings: count => `Журнал (${count})`,
359 none: 'Пока ничего не найдено.',
360 at: (file, line) => `${file}:${line}`,
361 textLine: line => `строка ${line} текста`,
362 legendRead: 'Так это прочитала модель (› строка с секретом):',
363 legendSaw: 'Модель видела значение; здесь оно замаскировано (› строка с секретом):',
364 legendNothing: 'Модель ничего из этого не получила; текст был таким, значения замаскированы (› строка с секретом):',
365 inFile: 'в файле:',
366 inText: 'в тексте:',
367 sourceLabel: 'Источник:',
368 fileLabel: 'Файл:',
369 noPlace: 'Записано прошлой версией: файл и строки не сохранены.',
370 showValue: 'показать значение',
371 hideValue: 'скрыть значение',
372 valueLabel: 'Значение:',
373 valueWarning: 'Эту панель видите только вы, модель её не получает. Через 30 с значение снова скроется. Не вставляйте скриншот панели в чат: картинки уходят модели и не проверяются.',
374 valueGone: 'значение не сохранено (мод перезагружался)',
375 clear: 'очистить журнал',
376 secrets: count => `Секреты этой сессии (${count})`,
377 secretsHint: 'Каждое встреченное значение, по одному разу. Если забыть значение, при следующем появлении снова спросит.',
378 logHint: 'Что происходило, новое сверху. Очистка журнала ничего не забывает: известные секреты остаются известными.',
379 statusCut: 'вырезается без вопроса: модель его не видит',
380 statusNew: 'ещё не вырезался: при появлении спросит',
381 statusPassed: 'пропущен вами: модель его видит',
382 statusAllowed: 'отмечен «не секрет»: модель его видит',
383 seen: count => `встречался ${count} ${timesRu(count)}`,
384 last: (time, source) => `последний раз ${time}, ${source}`,
385 forgetAll: 'забыть все',
386 cutButton: 'снова вырезать',
387 tabSession: 'Эта сессия',
388 pendingTitle: 'Ждёт вашего ответа в диалоге',
389 pendingLegend: 'Модель ещё ничего из этого не получила; текст, значения замаскированы (› строка с секретом):',
390 tabHistory: 'Вся история',
391 historyHint: 'Все сессии на этой машине, новые сверху, до 400 событий. Хранится на диске: маски и строки вокруг, никогда не значения и не хэши.',
392 historyNone: 'Истории пока нет.',
393 clearHistory: 'очистить всю историю',
394 dropSession: 'удалить',
395 sessionHeader: (date, project, session, count, isThis) =>
396 `${date} · ${project} · ${session}${isThis ? ' (эта сессия)' : ''} · ${count} ${eventsRu(count)}`,
397 allowButton: 'пропускать дальше',
398 installButton: 'установить gitleaks',
399 forgetButton: 'забыть',
400 },
401}
402
403export function textsFor(language: unknown): Texts {
404 return language === 'ru' ? RU : EN
405}
406
407/** The secrets of a dialog: rule and mask, three at most. */
408export function describe(found: readonly Found[], lang: Lang): string {
409 const distinct = uniqueByHash(found)
410 const shown = distinct.slice(0, 3).map(({ leak }) => `${leak.rule} ${mask(leak.secret)}`)
411 const rest = distinct.length - 3
412 const more = rest > 0 ? (lang === 'ru' ? ` и ещё ${rest}` : ` and ${rest} more`) : ''
413
414 return `${shown.join(', ')}${more}`
415}
416
417/** A tool call as the dialog and the journal name it: `Bash: cat .env`. */
418export function describeCall(tool: string, input: Record<string, unknown>, prefix: string): string {
419 const detail = [input.command, input.file_path, input.url, input.pattern, input.path].find(
420 value => typeof value === 'string' && value !== '',
421 ) as string | undefined
422 return `${prefix}${tool}${detail === undefined ? '' : `: ${detail}`}`
423}
424
425/** The key an answer was given for, or the fallback for anything else. */
426export function keyOf<O extends Record<string, string>>(
427 labels: O,
428 answer: string,
429 fallback: keyof O & string,
430): keyof O & string {
431 return (Object.keys(labels) as (keyof O & string)[]).find(key => labels[key] === answer) ?? fallback
432}
433
434export function clock(at: number): string {
435 return new Date(at).toTimeString().slice(0, 8)
436}
437
438/** The first absolute path a text names, as a changed-file note does. */
439export function pathIn(text: string): string | undefined {
440 const match = /(?:^|[\s'"`(])(\/[^\s'"`()]+)/.exec(text)
441
442 return match?.[1]?.replace(/[.,:;]+$/, '')
443}
444
445/** A path as the pane shows it: under the project root relative, elsewhere whole. */
446export function shortPath(path: string, root: string): string {
447 return root !== '' && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
448}
449
450/** A text cut to `max` characters, for a dialog's one line. */
451export function clip(text: string, max: number): string {
452 return text.length > max ? `${text.slice(0, max - 1)}…` : text
453}
454
455/** «раз» or «раза», as Russian counts times. */
456function timesRu(count: number): string {
457 const ten = count % 10
458 const hundred = count % 100
459
460 return ten >= 2 && ten <= 4 && (hundred < 12 || hundred > 14) ? 'раза' : 'раз'
461}
462
463/** «событие», «события» or «событий», as Russian counts events. */
464function eventsRu(count: number): string {
465 const ten = count % 10
466 const hundred = count % 100
467 if (ten === 1 && hundred !== 11) return 'событие'
468
469 return ten >= 2 && ten <= 4 && (hundred < 12 || hundred > 14) ? 'события' : 'событий'
470}
471
472/** A day and time, as the history groups sessions: `07.10 11:42`. */
473export function stamp(at: number): string {
474 const date = new Date(at)
475 const two = (n: number) => String(n).padStart(2, '0')
476
477 return `${two(date.getDate())}.${two(date.getMonth() + 1)} ${two(date.getHours())}:${two(date.getMinutes())}`
478}
479types/index.d.ts 138 lines1/**
2 * What became of a secret the scanner found.
3 *
4 * - `redacted`: cut out on the person's choice
5 * - `recut`: cut again without asking, a value cut before
6 * - `hidden`: the whole tool output withheld on the person's choice
7 * - `passed`: shown to the model on the person's choice
8 * - `allowlisted`: marked as no secret, passed from now on
9 * - `dropped`: the prompt was not sent, or the file not attached
10 * - `auto-redacted`: cut out where no dialog could be shown
11 * - `withheld`: the scanner failed and the text was withheld
12 */
13export type Decision =
14 | 'redacted'
15 | 'recut'
16 | 'hidden'
17 | 'passed'
18 | 'allowlisted'
19 | 'dropped'
20 | 'auto-redacted'
21 | 'withheld'
22
23/**
24 * One row of the pane's journal. Never holds a secret: `mask` shows at most
25 * its first four characters and its length, `hash` is a SHA-256 prefix.
26 */
27export type Entry = {
28 seq: number
29 /** The number the model reads in `[SECRET:<rule>#<label>]`; 0 for none. */
30 label: number
31 at: number
32 source: string
33 rule: string
34 mask: string
35 hash: string
36 decision: Decision
37 /** The file the secret stood in, when the text says which: under the project root relative. */
38 file?: string
39 /** The same file's path as the text gave it, whole. */
40 filePath?: string
41 /** Its line: in `file` when `isFileLine`, else in the text that was checked. */
42 line?: number
43 isFileLine?: boolean
44 /** The lines carry their own numbers (a Read's, a Grep's); else `n` numbers them. */
45 isNumbered?: boolean
46 /**
47 * The lines around it: `text` as the model read it (placeholders where it
48 * read none, masks where it saw the value), `inFile` as the text holds it
49 * with the value masked, where the two differ; `n` the line in the text.
50 */
51 lines?: { n: number; text: string; isHit: boolean; inFile?: string }[]
52}
53
54/**
55 * A secret met this session. Whether the model may read it is the allowlist's
56 * to say (`allowed`); one with a `number` was cut and is cut again without a
57 * question until the person forgets it.
58 */
59export type Known = {
60 hash: string
61 /** The number in `[SECRET:<rule>#<number>]`; 0 for a secret never cut. */
62 number: number
63 rule: string
64 mask: string
65 firstAt: number
66 lastAt: number
67 /** How many times it was journaled. */
68 seen: number
69 lastSource: string
70}
71
72/**
73 * One event of the history kept across sessions, in the plugin's store on
74 * disk: a journal row with no hash (a short secret's hash could be brute
75 * forced) and its lines cut down to the ones around the secret.
76 */
77export type HistoryEntry = Omit<Entry, 'hash' | 'seq'> & {
78 id: string
79 session: string
80 project: string
81}
82
83/** Where a secret stands in the text it was found in, as the pane shows it. */
84export type Place = {
85 file?: string
86 filePath?: string
87 line?: number
88 isFileLine?: boolean
89 isNumbered?: boolean
90 lines?: { n: number; text: string; isHit: boolean; inFile?: string }[]
91}
92
93/** What the dialog open right now asks about, so the pane can show it beside. */
94export type Pending = {
95 source: string
96 at: number
97 items: ({ hash: string; rule: string; mask: string } & Place)[]
98}
99
100/** A secret the model may read: passed once, or marked as no secret. */
101export type Allowed = {
102 hash: string
103 rule: string
104 mask: string
105 reason: 'passed' | 'allowlisted'
106}
107
108export type Scanner = {
109 status: 'unknown' | 'ok' | 'missing' | 'installing'
110 detail: string
111}
112
113declare module 'claude-code' {
114 interface PluginState {
115 'secret-guard': {
116 entries: Entry[]
117 allowed: Allowed[]
118 /** Every secret met this session, by hash: the registry the pane manages. */
119 known: Record<string, Known>
120 /** The last placeholder number given; never reused, a forgotten secret's included. */
121 lastNumber: number
122 scanner: Scanner
123 /** The journal rows the person opened in the pane, by `seq`. */
124 expanded: number[]
125 /** The secrets whose value the person is shown right now, by hash. */
126 revealed: string[]
127 /** The finding the open dialog asks about; null when none is open. */
128 pending: Pending | null
129 /** The pane's tab. */
130 tab: 'session' | 'history'
131 /** The history events the person opened, by id. */
132 openedHistory: string[]
133 /** Bumped whenever the stored history changes, so the pane draws it again. */
134 historyVersion: number
135 }
136 }
137}
138