SLOPSHOPPER

secret-guard

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.

newpaneguardcommandtoaststatus
★ 1v0.11.1MITupdated 2026-10-07legostin/claude-code-secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
│ ┃ secret-guard ✕ › fix the failing auth test and add an audit log call │ ┃ Scanner: gitleaks │ ┃ Secrets caught on their way to the model. No ⏺ Read(src/auth.ts) │ ┃ this pane is sent to it. Press a log entry t ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ This session ] [ All history ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Secrets this session (0) ⎿ 3 pass, 1 fail │ ┃ Each value met, once. Forget it and you are │ ┃ the next time it appears. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Nothing found yet. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Log (0) › /secrets │ ┃ What happened, newest first. Clearing it for ⎿ secret-guard: secret-guard pane opened. │ ┃ nothing: known secrets stay known. │ ┃ Nothing found yet. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · secret-guard
Scanner: gitleaks Secrets caught on their way to the model. Nothing in this pane is sent to it. Press a log entry to open it. [ This session ] [ All history ] Secrets this session (0) Each value met, once. Forget it and you are asked again the next time it appears. Nothing found yet. Log (0) What happened, newest first. Clearing it forgets nothing: known secrets stay known. Nothing found yet.
README

secret-guard: keep secrets out of Claude Code's context

CI License: MIT

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.

Русская версия · Website

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.

Why

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:

  • Before the model reads anything. It hooks the points where text enters the context, not the transcript afterwards. Once a secret has been sent, removing it later does not take it back.
  • The value never reaches the model. Dialogs and the journal show a mask (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.
  • When in doubt, it hides. A dismissed dialog, a scanner failure or a crashed hook withholds the text. Nothing passes silently.

What it checks

Where text enters the contextHookOn a finding
Output of any tool: Bash, Read, Grep, WebFetch, MCP tools, subagentstool.callDialog: cut / hide whole output / pass / not a secret
Your own prompt (a pasted key)prompt.submitDialog: cut / send as is / don't send (the text goes back to the input box)
A file mentioned with @pathprompt.mentionDialog: cut / don't attach / attach as is
CLAUDE.md, reminders, injected attachments (a changed-file note, a queued prompt…)prompt.context, prompt.attachmentCut automatically and journaled
The system prompt's sectionsprompt.composeCut automatically and journaled
Every other row stored in the conversationsession.appendSafety 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.

Detection

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:

LayerCatchesOption
Config assignmentsa 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 leftkeywordRules
Keyword rulesa 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- keyskeywordRules
Entropy rulea 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 tokensentropyRule
Random words in your prompta 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.

Install

You need:

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.

Use

  • While a dialog is open, the pane opens on its own and shows, at its top, what the question is about: the source, 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:

  • show the value: the value itself, in the pane only, for 30 seconds;
  • allow from now on or cut again;
  • forget: the next time the value appears, you are asked again.

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:

  • the rule and the mask, and what happened in plain words;
  • where it stood, as file:line when the text says (a Read, a Grep match, a changed-file note, an @-file), otherwise the line in the text;
  • the lines around it (three each side; six once opened). The line with the secret appears both as the model read it (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.

  • The status line shows secret-guard: N hidden · /secrets once something has been withheld. If gitleaks is missing, it shows the install command.
  • Language. The dialogs and the pane speak English or Russian. Run /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.
  • Project rules. gitleaks picks up .gitleaks.toml, .gitleaksignore and gitleaks:allow comments from the session's project root.

What the model sees

  • [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.
  • A refusal (the user withheld this Bash output from you…) when you hide a whole output.
  • A short system-prompt section asking it not to try to recover withheld values. It is told to use secrets indirectly (environment variables, files a command reads) or ask you.

Guarantees and limits

What is verified (see tests/ and the live checks in docs/design.md):

  • A cut or hidden secret appears neither in the request sent to the model nor in the session transcript file. Both the tool result the model reads and the record the transcript keeps for the screen are rewritten.
  • A dismissed dialog hides the output. In a headless run (claude -p) no dialog can be shown, so every finding is hidden.
  • If gitleaks is missing or fails, you are asked; when it is missing and Homebrew is there, installing it is the first choice, and the text is checked right after. Where nobody can be asked, the text is withheld and the agent reads how to install gitleaks. A check cut short (you interrupted) asks nothing and is not logged as a scanner failure.

What it does not do:

  • Short passwords are left alone by design. A word under 8 characters is not judged by its shape: such a password guards little anyway, and words that short read as random far too often. After a keyword (пароль: …) a value is caught from 6 characters.
  • Shannon entropy can't tell a short password from a word. A string of n characters has at most log₂(n) bits per character, so 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.
  • A password of letters only reads as a word (password: hunterHunter), and a hex key looks like a commit hash. Both are let through, to keep the false positives down.
  • The entropy rule is noisy on minified code: about 8–19 false findings in a 0.3–4.5 MB minified bundle. Turn entropyRule off if your agent reads those a lot.
  • WebFetch hands the whole page to a small helper model before secret-guard sees the result. A secret on a fetched page reaches that model. Fetch with curl through Bash when that matters: there the output is checked before anything reads it.
  • A secret split across several outputs can get through.
  • Images and screenshots are not scanned.
  • "Let the model see it" means exactly that. Once you pass a value, the model has it.
  • The terminal may briefly draw a tool's raw output before it is rewritten. That happens on your screen only: the model and the transcript never get that form.
  • If the mod is not loaded (disabled, or a broken install), nothing is protected. Check the status line or /secrets.
  • Mods are an early-access Claude Code API and may change between releases.

Develop

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.

License

MIT © Legostin Vyacheslav

Source 6 files
hooks/register.tsx 1339 lines
1// 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)}  `)
1200
hooks/redact.ts 324 lines
1// 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}
324
hooks/rules.ts 162 lines
1// 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}
162
hooks/words.ts 124 lines
1// 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}
124
hooks/texts.ts 479 lines
1// 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}
479
types/index.d.ts 138 lines
1/**
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