Stops secrets before they land: scans Write/Edit and git add/commit for keys and tokens, blocks git add of .env, keeps pasted keys out of the chat as…

Stops API keys and tokens before they land in a file or in git, and offers to move them into .env.
/plugin marketplace add partalemre/MODZ
/plugin install secret-guard@modz
Or in one step: /plugin install secret-guard --marketplace partalemre/MODZ.
It runs before every Write, Edit, MultiEdit and NotebookEdit, and before Bash git add and git commit, in every permission mode.
sk-... keys (Anthropic sk-ant-..., OpenAI-style), Stripe sk_live_ and rk_live_, AWS AKIA... and ASIA... ids and aws_secret_access_key = ..., GitHub ghp_, gho_, ghu_, ghs_, ghr_ and github_pat_, and -----BEGIN ... PRIVATE KEY-----.service_role JWTs. The payload is decoded locally; anon keys pass.*KEY, *SECRET, *TOKEN or *PASSWORD assignment whose value looks random (at least 12 characters, Shannon entropy of 3.5 or more, and a digit or 24 or more characters). Placeholders (your-..., xxx, example, ${...}, process.env..., member access such as config.secretKey) are ignored.git add of .env, .env.local, .env.production and the like, named directly or picked up by git add .. Templates such as .env.example are fine. git commit scans the added lines of the staged diff (plus the unstaged diff for -a) and any staged .env* names. Files staged by git add are scanned, up to 1 MB each and 300 files.When nobody can answer the question (a subagent's call, or a -p run with nothing drawing), the call is denied with the reason and nothing is moved.
Not flagged: writing to a .env* file itself (that is where secrets belong), setting keys with a dot such as done-ping.thresholdSeconds, and fake key-like values in test files (*.test.ts, *.spec.js, tests/, fixtures/, __mocks__/): generic assignments, and key shapes whose value says it is fake (test, fake, dummy, mock, sample, example, fixture).
A blocking question with a card above it: red for a leak, amber for an env file. The card names the file or git command, says what it found (This write has 2 secret keys, This commit has ..., .env.local is being added to git), then lists up to 5 findings, each with a detector chip (Anthropic, Stripe, GitHub, Supabase, AWS, Private key, or the variable name), the line, and the value masked (eyJh****). The full secret is never shown, logged, or put in a denial message.
LEAK BLOCKED · src/lib/supabase.ts
This write has 2 secret keys
Supabase service_role key eyJh**** line 4
Anthropic API key sk-a**** line 9
The question is "How do you want to go on?", the safe fix first, each answer with a line saying what it does:
NAME=value to the .env at the root of the git repository that holds the file (a worktree's own root, never the session's folder when they differ; outside any repository, the session's folder), reusing an entry that holds the same value and renaming a taken name, adds .env to that repository's .gitignore, and rewrites the write so the code reads the environment: process.env.NAME (JS/TS), os.environ["NAME"] (Python), Platform.environment['NAME'] (Dart), os.Getenv("NAME") (Go), ENV["NAME"] (Ruby), getenv('NAME') (PHP), ${NAME} elsewhere. Claude is told what moved. For git commands, private keys and test files the call is denied with an instruction for Claude to move the secrets itself.The desktop app draws the card as a picture; the terminal draws the same words as text. The card follows Claude Code's theme setting, or MODZ_THEME=light|dark in the env block of ~/.claude/settings.json.
A key, token or private key you paste into a prompt never reaches the model. The prompt goes out with ⟦secret:1⟧ in its place (the same value always gets the same number), and the system prompt gets one short note telling Claude that each placeholder is a real secret it uses verbatim and never asks for.
.env and the file reads it from the environment. Allow once, or a write to a .env file, keeps the placeholder as written, and Claude is told so.1 secret kept out of the chat; no toast.$.store, $.state, a file or a log. /clear forgets them; so does a reload of the mod, after which a command that still names a placeholder is stopped with the reason./rec turns recording mode on or off for this session (off by default). While it is on, the line under the prompt says REC, and the transcript's prompts, replies, tool calls and tool results draw with keys, NAME=value env values and e-mail addresses masked (●●●●Jk: at most the last two characters). It only changes what is drawn: the transcript and what Claude reads stay as they were. It builds on whatever the rows below it drew, so it works with skins.
/secret-guard turns the scan on or off (remembered). Off, pasted secrets also stay in the prompt as typed./rec turns recording mode on or off (this session only).There are no plugin options.
Edit's new string, not the old one).git add and git commit (for example echo KEY=... > config.js) are not scanned.$ inside double quotes would still expand). Other tools get the placeholder.git add is dry-run from the session's working directory or git -C dir; a cd earlier in the same command is not followed.Uses no model tokens: it is plain pattern matching. While the session holds a placeholder, the system prompt carries a three-sentence note (~100 input tokens, counted from its words).
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 261 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { HEADER, cardTree, described } from './card'
5import { findSecrets } from './detect'
6import { blockedDeny, isMovable, moveYourselfDeny, pendingOf, unattendedDeny } from './findings'
7import type { Found } from './findings'
8import { gitSteps, isEnvFile, scanStep } from './git'
9import type { GitHost } from './git'
10import { maskTree } from './mask'
11import { appendText, envLines, envRootOf, hasEnvIgnore, parseEnv, planEntries, rewrite, withTokens } from './move'
12import { bashCommand, scrubbedContent, withNote, withTokenNote } from './placeholders'
13import { maskedProps } from './rec'
14import { isNewStatus, statusText } from './status'
15import { fileText, isFileTool, patchedFields, pathOf } from './tools'
16import { clearVault, hasTokens, hide, sessionVault, tokenHits } from './vault'
17import { setLang } from './lang'
18import { resolveLight } from './theme'
19import { words } from './words'
20
21type On = Parameters<Register>[0]
22
23const COMMAND = 'secret-guard'
24const REC_COMMAND = 'rec'
25const STORE_KEY = 'secret-guard.enabled'
26const DEFAULT_COLUMNS = 100
27const FILE_MAX_BYTES = 1_000_000
28
29const pendingAtom = atom({ plugin: 'secret-guard', key: 'pending' } as const, null)
30const enabledAtom = atom({ plugin: 'secret-guard', key: 'isEnabled' } as const, true)
31// Recording mode is the session's: off at start, kept across a reload of the mod.
32const recordingAtom = atom({ plugin: 'secret-guard', key: 'isRecording' } as const, false)
33const session = { cwd: '', isLight: false }
34
35async function scanFile($: EngineInterface, path: string): Promise<string> {
36 try {
37 const stat = await $.fs.stat(path)
38 if (stat.kind !== 'file' || stat.size > FILE_MAX_BYTES) return ''
39
40 return await $.fs.read(path)
41 } catch {
42 return ''
43 }
44}
45
46function gitHost($: EngineInterface): GitHost {
47 const git = async (argv: string[], cwd: string) => {
48 const ran = await $.process.run(['git', ...argv], { cwd })
49
50 return ran.exitCode === 0 ? ran.stdout : ''
51 }
52
53 return { cwd: session.cwd, git, read: path => scanFile($, path) }
54}
55
56async function inspect($: EngineInterface, input: Record<string, unknown>): Promise<Found | null> {
57 const found: Found = { secrets: [], envFiles: [], path: null, command: String(input.command ?? '') }
58 if (isFileTool(input)) {
59 const path = pathOf(input)
60 if (path === null || isEnvFile(path)) return null
61 found.path = path
62 const text = fileText(input)
63 found.secrets = [...findSecrets(text, path), ...tokenHits(sessionVault, text)].map(secret => ({ ...secret, where: `${path}:${secret.line}` }))
64 } else if (input.tool === 'Bash' && typeof input.command === 'string') {
65 for (const step of gitSteps(input.command)) await scanStep(gitHost($), step, found)
66 }
67 found.envFiles = [...new Set(found.envFiles)]
68 found.secrets = [...new Map(found.secrets.map(secret => [secret.token ?? secret.value, secret])).values()]
69
70 return found.secrets.length + found.envFiles.length === 0 ? null : found
71}
72
73async function askUser($: EngineInterface, found: Found): Promise<string> {
74 const pending = pendingOf(found, session.cwd)
75 const options = pending.options.map(option => option.label)
76 await update($, pendingAtom, () => pending)
77 let answer = ''
78 try {
79 answer = await $.ui.ask(words().question, { options, header: HEADER })
80 } catch {
81 $.ui.log('secret-guard: question dismissed, call cancelled', { to: 'debug' })
82 }
83 await update($, pendingAtom, () => null)
84
85 return answer
86}
87
88async function writeEnv($: EngineInterface, found: Found) {
89 const { root, isRepo } = await envRootOf({ path: found.path ?? session.cwd, cwd: session.cwd, exists: path => $.fs.exists(path) })
90 const envPath = `${root}/.env`
91 const existing = (await $.fs.exists(envPath)) ? await $.fs.read(envPath) : ''
92 const entries = planEntries(found.secrets, parseEnv(existing))
93 await $.fs.write(envPath, appendText(existing, envLines(entries)))
94 if (isRepo) {
95 const ignorePath = `${root}/.gitignore`
96 const ignore = (await $.fs.exists(ignorePath)) ? await $.fs.read(ignorePath) : ''
97 if (!hasEnvIgnore(ignore)) await $.fs.write(ignorePath, appendText(ignore, '.env\n'))
98 }
99
100 return { entries, envPath }
101}
102
103// A subagent's call, or a session nothing draws (a -p run): nobody can answer the card, so nothing is moved.
104async function isUnattended($: EngineInterface, e: { agentId?: string }): Promise<boolean> {
105 return e.agentId !== undefined || (await $.session.surfaces()).length === 0
106}
107
108async function showStatus($: EngineInterface): Promise<void> {
109 const text = statusText({ isRecording: await read($, recordingAtom), keptCount: sessionVault.byValue.size })
110 if (isNewStatus(text)) $.ui.status(text)
111}
112
113async function refreshTheme($: EngineInterface): Promise<void> {
114 const rows = await $.config.list()
115 const hints = { override: await $.env.get('MODZ_THEME'), theme: rows.find(row => row.key === 'theme')?.value, colorfgbg: await $.env.get('COLORFGBG') }
116 if (resolveLight(hints) === resolveLight({ ...hints, systemDark: false })) {
117 session.isLight = resolveLight(hints)
118
119 return
120 }
121 const mac = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 })
122 session.isLight = resolveLight({ ...hints, systemDark: mac.exitCode === 0 ? mac.stdout.trim() === 'Dark' : false })
123}
124
125function hookSession(on: On): void {
126 on('session.start', async ($, e, next) => {
127 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
128 try {
129 session.cwd = e.cwd
130 const saved = await $.store.get(STORE_KEY)
131 await update($, enabledAtom, () => saved !== false)
132 await $.command.register({ name: COMMAND, description: words().commandLine })
133 await $.command.register({ name: REC_COMMAND, description: words().recCommandLine })
134 await showStatus($)
135 } catch {
136 $.ui.log('secret-guard: could not load settings', { to: 'debug' })
137 }
138 await refreshTheme($).catch(() => $.ui.log('secret-guard: could not read the theme, drawing dark', { to: 'debug' }))
139
140 return next(e)
141 })
142 // A cleared chat holds no placeholder any more: the values go with it.
143 on('session.end', async ($, e, next) => {
144 if (e.reason === 'clear') {
145 clearVault(sessionVault)
146 await showStatus($)
147 }
148
149 return next(e)
150 })
151}
152
153function hookCommand(on: On): void {
154 on('command.run', { command: COMMAND }, async $ => {
155 const isOn = !(await read($, enabledAtom))
156 await update($, enabledAtom, () => isOn)
157 await $.store.set(STORE_KEY, isOn)
158
159 return { text: words().toggled(isOn) }
160 })
161 on('command.run', { command: REC_COMMAND }, async $ => {
162 const isOn = !(await read($, recordingAtom))
163 await update($, recordingAtom, () => isOn)
164 await showStatus($)
165
166 return { text: words().recToggled(isOn) }
167 })
168}
169
170function hookCall(on: On): void {
171 on('tool.call', async ($, e, next) => {
172 if (!(await read($, enabledAtom))) return next(e)
173 const input = e as unknown as Record<string, unknown>
174 let found: Found | null = null
175 try {
176 found = await inspect($, input)
177 } catch {
178 $.ui.log('secret-guard: scan failed, call passed through', { to: 'debug' })
179 }
180 if (found === null) return withTokenNote(input, await next(e))
181 if (await isUnattended($, e)) return { deny: unattendedDeny(found) }
182
183 const answer = await askUser($, found)
184 if (answer === words().allow) return withTokenNote(input, await next(e))
185 if (found.secrets.length === 0 || answer !== words().move(found.secrets.length)) return { deny: blockedDeny(found) }
186 if (!isMovable(found)) return { deny: moveYourselfDeny(found) }
187 const { entries, envPath } = await writeEnv($, found)
188 const path = found.path ?? ''
189 const spelled = withTokens(entries, found.secrets.map(secret => secret.token))
190 const ran = await next({ ...e, ...patchedFields(input, text => rewrite(text, { path, entries: spelled })) } as typeof e)
191 const note = `secret-guard moved ${entries.length} secret(s) from ${path} into ${envPath} as ${entries.map(entry => entry.name).join(', ')}; the file now reads them from the environment. Make sure the program loads .env.`
192
193 return ran.deny === undefined ? { ...ran, context: [...(ran.context ?? []), note] } : ran
194 })
195}
196
197// Placeholders: the prompt hides pasted secrets, Bash gets them back, tool output hides them again.
198function hookPlaceholders(on: On): void {
199 on('prompt.submit', async ($, e, next) => {
200 if (!(await read($, enabledAtom))) return next(e)
201 const text = hide(sessionVault, e.text)
202 if (text === e.text) return next(e)
203 const entered = await next({ ...e, text })
204 await showStatus($)
205
206 return entered
207 })
208 on('prompt.compose', async ($, e, next) => {
209 const composed = await next(e)
210
211 return { ...composed, sections: withNote(composed.sections) }
212 })
213 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
214 if (e.tool !== 'Bash' || !hasTokens(e.command)) return next(e)
215 const revealed = bashCommand(e.command)
216
217 return 'deny' in revealed ? revealed : next({ ...e, command: revealed.command })
218 })
219 on('session.append', async ($, e, next) => {
220 const content = scrubbedContent({ door: e.door, content: e.message.content })
221
222 return content === null ? next(e) : next({ ...e, message: { ...e.message, content } })
223 })
224}
225
226function hookRender(on: On): void {
227 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
228 const pending = await read($, pendingAtom)
229 if (pending === null) return next(e)
230 const ui = $.ui.resolve(e)
231 const { Box } = ui
232 const theirs = await next({ ...e, props: { ...e.props, questions: described(e.props.questions, pending) } })
233 const spec = { width: e.viewport?.columns ?? DEFAULT_COLUMNS, isLight: session.isLight, surface: e.surface }
234
235 return (
236 <Box flexDirection="column" rowGap={1}>
237 {cardTree(ui, pending, spec)}
238 {theirs}
239 </Box>
240 )
241 })
242 // Recording mode masks the props on the way down, for the engine's own drawing, and the tree that comes
243 // back, for whatever a hook beneath drew (a skin), so it holds whichever way the plugins are stacked.
244 on('ui.render', { component: ['UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult'] }, async ($, e, next) => {
245 if (!(await read($, recordingAtom))) return next(e)
246 const known = [...sessionVault.byValue.keys()]
247 const props = maskedProps(e.component, e.props, known)
248 const tree = await next(props === e.props ? e : ({ ...e, props } as typeof e))
249
250 return maskTree(tree, known)
251 })
252}
253
254export const register: Register = on => {
255 hookSession(on)
256 hookCommand(on)
257 hookCall(on)
258 hookPlaceholders(on)
259 hookRender(on)
260}
261hooks/card.tsx 85 lines1import { confirmCard } from './confirm'
2import type { CardModel, Ui } from './confirm'
3import { desktopCard, headlineOf } from './svg-card'
4import type { GuardOption, GuardPending } from '../types'
5import { words } from './words'
6
7export const HEADER = 'secret-guard'
8
9const DETECTOR_CHIPS: [RegExp, string][] = [
10 [/^Anthropic/, 'Anthropic'],
11 [/^sk- /, 'OpenAI'],
12 [/^Stripe/, 'Stripe'],
13 [/^AWS/, 'AWS'],
14 [/^GitHub/, 'GitHub'],
15 [/^Supabase/, 'Supabase'],
16]
17const PRIVATE_KEY = /^private key/
18
19/** The chip a finding wears: the detector, or the variable name a generic match was assigned to. */
20export function chipOf(label: string): string {
21 if (PRIVATE_KEY.test(label)) return words().privateKey
22
23 return DETECTOR_CHIPS.find(([pattern]) => pattern.test(label))?.[1] ?? label.replace(/ value$/, '')
24}
25
26/** The dialog's answers, each with the line that says what it does. The safe fix comes first: Enter takes it. */
27export function optionsOf(found: { secretCount: number; isMovable: boolean }): GuardOption[] {
28 const said = words()
29 const allow = { label: said.allow, description: said.allowLine }
30 const cancel = { label: said.cancel, description: said.cancelLine }
31 if (found.secretCount === 0) return [cancel, allow]
32
33 return [{ label: said.move(found.secretCount), description: said.moveLine(found.isMovable) }, allow, cancel]
34}
35
36// An env file staged for git is no secret value but is shown among them. Alone, its path stands where a
37// location would; beside secrets it takes the value column (`being added to git`), as on the desktop card.
38function rowsOf(pending: GuardPending): string[][] {
39 const isAlone = pending.secrets.length === 0
40 const said = words()
41 const env = pending.envFiles.map(path => (isAlone ? [said.envFile, '', path] : [path, said.toGit, '']))
42 const secrets = pending.secrets.map(secret => [secret.label, secret.masked, secret.where])
43
44 return [...env, ...secrets]
45}
46
47export function cardModel(pending: GuardPending): CardModel {
48 const rows = rowsOf(pending)
49 const head = headlineOf(pending)
50
51 return {
52 tone: pending.secrets.length === 0 ? 'warning' : 'error',
53 eyebrow: head.eyebrow,
54 subject: pending.subject,
55 headline: head.title,
56 columns: [{ align: 'left', isDim: false }, { align: 'left', isDim: false, isToned: true }, { align: 'left', isDim: true }],
57 rows,
58 total: rows.length,
59 }
60}
61
62// Desktop draws the vector card; the terminal keeps the text card, both within the dialog's rows.
63export function cardTree(ui: Ui, pending: GuardPending, spec: { width: number; isLight: boolean; surface: string }) {
64 if (spec.surface !== 'terminal' && 'Svg' in ui) return desktopCard(ui, pending, spec)
65
66 return confirmCard(ui, cardModel(pending), spec.width)
67}
68
69// The guard's own question gets a line under each answer saying what it does.
70export function described(questions: unknown[], pending: GuardPending): unknown[] {
71 const lineOf = new Map(pending.options.map(option => [option.label, option.description]))
72
73 return questions.map(question => {
74 const asked = question as { question?: unknown; options?: unknown }
75 if (asked.question !== words().question || !Array.isArray(asked.options)) return question
76 const options = asked.options.map(option => {
77 const label = (option as { label?: unknown }).label
78
79 return typeof label === 'string' && lineOf.has(label) ? { ...(option as object), description: lineOf.get(label) } : option
80 })
81
82 return { ...asked, options }
83 })
84}
85hooks/detect.ts 134 lines1import { isServiceRoleJwt } from './jwt'
2
3export type Secret = {
4 kind: string
5 label: string
6 // The raw value stays in memory for the rewrite; only `mask(value)` is ever shown or logged.
7 value: string
8 envName: string
9 line: number
10 isMovable: boolean
11}
12
13type Rule = { kind: string; label: string; pattern: RegExp; envName: string; group?: number; isMovable?: boolean }
14
15const ENTROPY_MIN = 3.5
16const GENERIC_MIN = 12
17const GENERIC_LONG = 24
18const MASK_KEEP = 4
19const SCAN_MAX_CHARS = 2_000_000
20
21const RULES: Rule[] = [
22 { kind: 'anthropic', label: 'Anthropic API key', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, envName: 'ANTHROPIC_API_KEY' },
23 { kind: 'openai', label: 'sk- API key', pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}/g, envName: 'OPENAI_API_KEY' },
24 { kind: 'stripe', label: 'Stripe live key', pattern: /\b(?:sk|rk)_live_[0-9a-zA-Z]{16,}/g, envName: 'STRIPE_SECRET_KEY' },
25 { kind: 'aws-id', label: 'AWS access key id', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, envName: 'AWS_ACCESS_KEY_ID' },
26 {
27 kind: 'aws-secret',
28 label: 'AWS secret key',
29 pattern: /aws_?secret_?access_?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})/gi,
30 envName: 'AWS_SECRET_ACCESS_KEY',
31 group: 1,
32 },
33 { kind: 'github', label: 'GitHub token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g, envName: 'GITHUB_TOKEN' },
34 { kind: 'private-key', label: 'private key', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/g, envName: 'PRIVATE_KEY', isMovable: false },
35]
36
37const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
38const GENERIC = /\b([A-Za-z_][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD))\b["']?\s*[:=]\s*(["'`]?)([^\s"'`;,()[\]{}]{12,})\2/gi
39const PLACEHOLDER = /process\.env|os\.environ|getenv|\$\{|<|>|your|xxx|example|changeme|placeholder|dummy|redacted|\*\*\*/i
40const MEMBER_ACCESS = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/
41// Setting keys and slugs such as `done-ping.thresholdSeconds`: every segment starts with a letter.
42const DOTTED_NAME = /^[A-Za-z][A-Za-z0-9]*(?:[-.][A-Za-z][A-Za-z0-9]*)+$/
43const TEST_PATH = /(^|\/)(tests?|__tests__|fixtures?|__mocks__)\/|\.(test|spec)\.[cm]?[jt]sx?$/
44// Test files hold fake values on purpose; only real key shapes are reported there.
45const LENIENT_IN_TESTS = new Set(['generic', 'private-key'])
46// A key-shaped value in a test file that says it is fake (`sk-test-...`, `ghp_fake...`) is a fixture.
47const FAKE_VALUE = /test|fake|dummy|mock|sample|example|fixture/i
48
49export function isTestPath(path: string): boolean {
50 return TEST_PATH.test(path)
51}
52
53export function mask(value: string): string {
54 return `${value.slice(0, MASK_KEEP)}****`
55}
56
57export function entropy(text: string): number {
58 const counts = new Map<string, number>()
59 for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
60 let bits = 0
61 for (const count of counts.values()) {
62 const share = count / text.length
63 bits -= share * Math.log2(share)
64 }
65
66 return bits
67}
68
69function lineAt(text: string, index: number): number {
70 let line = 1
71 for (let at = 0; at < index; at += 1) if (text.charCodeAt(at) === 10) line += 1
72
73 return line
74}
75
76function isRandomLooking(value: string): boolean {
77 if (value.length < GENERIC_MIN || PLACEHOLDER.test(value) || MEMBER_ACCESS.test(value) || DOTTED_NAME.test(value)) return false
78 const hasDigit = /\d/.test(value)
79
80 return entropy(value) >= ENTROPY_MIN && (hasDigit || value.length >= GENERIC_LONG)
81}
82
83function envNameOf(name: string): string {
84 return name
85 .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
86 .replace(/[^A-Za-z0-9_]/g, '_')
87 .toUpperCase()
88}
89
90function ruleHits(text: string): Secret[] {
91 const found: Secret[] = []
92 for (const rule of RULES) {
93 for (const match of text.matchAll(rule.pattern)) {
94 const value = match[rule.group ?? 0] ?? ''
95 if (value === '') continue
96 found.push({ kind: rule.kind, label: rule.label, value, envName: rule.envName, line: lineAt(text, match.index ?? 0), isMovable: rule.isMovable !== false })
97 }
98 }
99 for (const match of text.matchAll(JWT)) {
100 if (!isServiceRoleJwt(match[0])) continue
101 found.push({ kind: 'supabase', label: 'Supabase service_role key', value: match[0], envName: 'SUPABASE_SERVICE_ROLE_KEY', line: lineAt(text, match.index ?? 0), isMovable: true })
102 }
103
104 return found
105}
106
107function genericHits(text: string, known: Set<string>): Secret[] {
108 const found: Secret[] = []
109 for (const match of text.matchAll(GENERIC)) {
110 const name = match[1] ?? ''
111 const value = match[3] ?? ''
112 if ([...known].some(secret => secret.includes(value) || value.includes(secret)) || !isRandomLooking(value)) continue
113 found.push({ kind: 'generic', label: `${name} value`, value, envName: envNameOf(name), line: lineAt(text, match.index ?? 0), isMovable: true })
114 }
115
116 return found
117}
118
119export function findSecrets(text: string, path = ''): Secret[] {
120 const isTest = isTestPath(path)
121 const scanned = text.length > SCAN_MAX_CHARS ? text.slice(0, SCAN_MAX_CHARS) : text
122 const specific = ruleHits(scanned)
123 const all = [...specific, ...genericHits(scanned, new Set(specific.map(secret => secret.value)))]
124 const seen = new Set<string>()
125
126 return all.filter(secret => {
127 if (isTest && (LENIENT_IN_TESTS.has(secret.kind) || FAKE_VALUE.test(secret.value))) return false
128 if (seen.has(secret.value)) return false
129 seen.add(secret.value)
130
131 return true
132 })
133}
134hooks/findings.ts 60 lines1import { chipOf, optionsOf } from './card'
2import { isTestPath, mask } from './detect'
3import type { Secret } from './detect'
4import { gitSteps } from './git'
5import { words } from './words'
6import type { GuardPending } from '../types'
7
8// What one scan found, and how the card and the denial messages say it.
9
10// `token`: a finding that is one of this session's placeholders, `value` the real secret behind it.
11export type Finding = Secret & { where: string; token?: string }
12export type Found = { secrets: Finding[]; envFiles: string[]; path: string | null; command: string }
13
14function relative(path: string, cwd: string): string {
15 return cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
16}
17
18// A file write names the file in the card's title, so each finding says only its line.
19function sourceOf(found: Found): GuardPending['source'] {
20 if (found.path !== null) return 'write'
21
22 return gitSteps(found.command).some(step => step.kind === 'commit') ? 'commit' : 'add'
23}
24
25// Moving to .env rewrites the call itself only for a file write with no private key. A test file's keys are
26// fixtures more often than not: Claude is asked to deal with them instead of the guard moving them unseen.
27export const isMovable = (found: Found): boolean => found.path !== null && !isTestPath(found.path) && found.secrets.every(secret => secret.isMovable)
28
29// A placeholder is shown as itself: not even the first characters of the value behind it.
30const shownOf = (secret: Finding): string => secret.token ?? mask(secret.value)
31
32export function pendingOf(found: Found, cwd: string): GuardPending {
33 const where = (secret: Finding) => (found.path === null ? secret.where : words().line(secret.line))
34 const atOf = (secret: Finding) => (found.path === null ? relative(secret.where, cwd) : `${relative(found.path, cwd)}:${secret.line}`)
35 const secrets = [...found.secrets].sort((a, b) => a.line - b.line)
36
37 return {
38 subject: found.path === null ? found.command : relative(found.path, cwd),
39 source: sourceOf(found),
40 envFiles: found.envFiles,
41 secrets: secrets.map(secret => ({ label: secret.label, chip: chipOf(secret.label), masked: shownOf(secret), where: where(secret), at: atOf(secret) })),
42 options: optionsOf({ secretCount: found.secrets.length, isMovable: isMovable(found) }),
43 }
44}
45
46export function summary(found: Found): string {
47 return found.secrets.map(secret => `${secret.label} ${shownOf(secret)} (${secret.where})`).join(', ')
48}
49
50const exposed = (found: Found): string => summary(found) || found.envFiles.join(', ')
51
52export const unattendedDeny = (found: Found): string =>
53 `secret-guard blocked this call: it would expose ${exposed(found)}, and nobody can answer its question here, so nothing was moved. Keep secrets in .env (git-ignored) and read them from the environment, or leave this change to the main conversation.`
54
55export const blockedDeny = (found: Found): string =>
56 `The user blocked this call: it would expose ${exposed(found)}. Keep secrets in .env (git-ignored) and read them from the environment.`
57
58export const moveYourselfDeny = (found: Found): string =>
59 `The user chose "Move to .env" for ${summary(found)}. Move each secret into the project's .env (git-ignored), reference it from the environment, then retry.`
60hooks/git.ts 118 lines1import { findSecrets } from './detect'
2import type { Secret } from './detect'
3import { resolvePath } from './tools'
4
5export type GitStep =
6 | { kind: 'add'; cwd: string | null; args: string[] }
7 | { kind: 'commit'; cwd: string | null; isAll: boolean }
8
9// What a scan reaches the repository through: register.tsx makes these calls on `$`.
10export type GitHost = { cwd: string; git: (argv: string[], cwd: string) => Promise<string>; read: (path: string) => Promise<string> }
11export type GitFound = { secrets: (Secret & { where: string })[]; envFiles: string[] }
12
13const FILES_MAX = 300
14const SEPARATORS = /&&|\|\||;|\n|\|/
15const ENV_FILE = /^\.env(\..+)?$/
16const ENV_TEMPLATE = /\.(example|sample|template|dist)$/i
17// git's own options that take a value as the next word.
18const GIT_VALUE_OPTIONS = ['-C', '-c', '--git-dir', '--work-tree', '--namespace']
19
20// Shell words, quotes stripped; enough for reading git's arguments, not a full shell parser.
21export function words(segment: string): string[] {
22 const found: string[] = []
23 for (const match of segment.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)) {
24 found.push(match[1] ?? match[2] ?? match[3] ?? '')
25 }
26
27 return found
28}
29
30function stepOf(tokens: string[]): GitStep | null {
31 let at = 0
32 while (at < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[at] ?? '')) at += 1
33 if (tokens[at] !== 'git') return null
34 at += 1
35 let cwd: string | null = null
36 while (at < tokens.length && (tokens[at] ?? '').startsWith('-')) {
37 const option = tokens[at] ?? ''
38 if (option === '-C') cwd = tokens[at + 1] ?? null
39 at += GIT_VALUE_OPTIONS.includes(option) ? 2 : 1
40 }
41 const verb = tokens[at]
42 const rest = tokens.slice(at + 1)
43 if (verb === 'add') return { kind: 'add', cwd, args: rest }
44 if (verb === 'commit') return { kind: 'commit', cwd, isAll: rest.some(word => word === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(word)) }
45
46 return null
47}
48
49export function gitSteps(command: string): GitStep[] {
50 return command
51 .split(SEPARATORS)
52 .map(segment => stepOf(words(segment.trim())))
53 .filter((step): step is GitStep => step !== null)
54}
55
56export function baseName(path: string): string {
57 const parts = path.split('/')
58
59 return parts[parts.length - 1] ?? path
60}
61
62export function isEnvFile(path: string): boolean {
63 const name = baseName(path)
64
65 return ENV_FILE.test(name) && !ENV_TEMPLATE.test(name)
66}
67
68// `git add -n` prints one `add 'path'` line per file it would stage.
69export function dryRunPaths(stdout: string): string[] {
70 return stdout
71 .split('\n')
72 .map(line => /^add '(.*)'$/.exec(line.trim())?.[1])
73 .filter((path): path is string => path !== undefined)
74}
75
76// The added lines of a unified diff, grouped by the file they land in.
77export function addedByFile(diff: string): Map<string, string> {
78 const files = new Map<string, string>()
79 let current = ''
80 for (const line of diff.split('\n')) {
81 const header = /^\+\+\+ b\/(.*)$/.exec(line)
82 if (header !== null) {
83 current = header[1] ?? ''
84 continue
85 }
86 if (line.startsWith('+') && !line.startsWith('+++') && current !== '') {
87 files.set(current, `${files.get(current) ?? ''}${line.slice(1)}\n`)
88 }
89 }
90
91 return files
92}
93
94async function scanAdd(host: GitHost, step: Extract<GitStep, { kind: 'add' }>, found: GitFound): Promise<void> {
95 const dir = step.cwd === null ? host.cwd : resolvePath(host.cwd, step.cwd)
96 found.envFiles.push(...step.args.filter(isEnvFile))
97 const paths = dryRunPaths(await host.git(['add', '-n', ...step.args], dir))
98 found.envFiles.push(...paths.filter(isEnvFile))
99 for (const path of paths.filter(entry => !isEnvFile(entry)).slice(0, FILES_MAX)) {
100 const text = await host.read(resolvePath(dir, path))
101 found.secrets.push(...findSecrets(text, path).map(secret => ({ ...secret, where: `${path}:${secret.line}` })))
102 }
103}
104
105// `git add` scans the files it would stage; `git commit` the added lines of the staged diff (and the unstaged one for -a).
106export async function scanStep(host: GitHost, step: GitStep, found: GitFound): Promise<void> {
107 if (step.kind === 'add') return scanAdd(host, step, found)
108 const dir = step.cwd === null ? host.cwd : resolvePath(host.cwd, step.cwd)
109 const scopes = step.isAll ? [['--cached'], []] : [['--cached']]
110 for (const scope of scopes) {
111 const names = (await host.git(['diff', ...scope, '--name-only'], dir)).split('\n').filter(name => name !== '')
112 found.envFiles.push(...names.filter(isEnvFile))
113 for (const [path, added] of addedByFile(await host.git(['diff', ...scope, '-U0', '--no-color'], dir))) {
114 found.secrets.push(...findSecrets(added, path).map(secret => ({ ...secret, where: path })))
115 }
116 }
117}
118hooks/mask.ts 86 lines1import { findSecrets } from './detect'
2
3// Recording mode's masking: what the screen shows, never what the transcript keeps.
4
5const DOTS = '●●●●'
6// A value this long keeps its last two characters, so the person can still tell two keys apart.
7const REVEAL_FROM = 8
8const REVEAL_KEEP = 2
9const PEM = /(-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----)[\s\S]*?(-----END (?:[A-Z]+ )*PRIVATE KEY-----)/g
10// `NAME=value` with an upper-case name, as a shell line or an env file writes it.
11const ENV_ASSIGNMENT = /(^|[\s;(`'"])((?:export\s+)?[A-Z][A-Z0-9_]+)=(["']?)([^\s"'`;)]+)\3/gm
12const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g
13
14export function maskValue(value: string): string {
15 return value.length >= REVEAL_FROM ? `${DOTS}${value.slice(-REVEAL_KEEP)}` : DOTS
16}
17
18const isMasked = (value: string): boolean => value.startsWith(DOTS) || value.startsWith('⟦')
19
20function replaceValues(text: string, values: string[]): string {
21 return [...new Set(values)]
22 .filter(value => value !== '')
23 .sort((a, b) => b.length - a.length)
24 .reduce((out, value) => out.split(value).join(maskValue(value)), text)
25}
26
27// `known` are values this session holds behind placeholders: masked even where no detector would see them.
28export function maskText(text: string, known: readonly string[] = []): string {
29 if (text === '') return text
30 let out = replaceValues(text, [...known])
31 out = out.replace(PEM, (_, begin: string, end: string) => `${begin}${DOTS}${end}`)
32 out = replaceValues(out, findSecrets(out).filter(secret => secret.kind !== 'private-key').map(secret => secret.value))
33 out = out.replace(ENV_ASSIGNMENT, (whole, lead: string, name: string, quote: string, value: string) =>
34 (isMasked(value) ? whole : `${lead}${name}=${quote}${maskValue(value)}${quote}`))
35
36 return out.replace(EMAIL, maskValue)
37}
38
39const isPlainObject = (value: unknown): value is Record<string, unknown> =>
40 typeof value === 'object' && value !== null && !Array.isArray(value)
41
42// Every string inside plain data, masked; the same object back when nothing changed.
43export function maskDeep<T>(value: T, known: readonly string[]): T {
44 if (typeof value === 'string') return maskText(value, known) as T
45 if (Array.isArray(value)) {
46 const items = value.map(item => maskDeep(item, known))
47
48 return (items.some((item, index) => item !== value[index]) ? items : value) as T
49 }
50 if (!isPlainObject(value)) return value
51 const entries = Object.entries(value).map(([key, item]) => [key, maskDeep(item, known)] as const)
52
53 return (entries.some(([key, item]) => item !== value[key]) ? Object.fromEntries(entries) : value) as T
54}
55
56// The text props each element draws besides its children.
57const TEXT_PROPS: Record<string, readonly string[]> = { Markdown: ['text'], Code: ['source'], Svg: ['alt', 'source'] }
58
59type Node = { type?: unknown; props?: Record<string, unknown>; children?: unknown[] }
60
61function maskElement(node: Node, known: readonly string[]): Node {
62 const keys = TEXT_PROPS[String(node.type)] ?? []
63 const props = node.props === undefined ? undefined : { ...node.props }
64 let isChanged = false
65 for (const key of keys) {
66 const before = props?.[key]
67 if (props === undefined || typeof before !== 'string') continue
68 props[key] = maskText(before, known)
69 isChanged ||= props[key] !== before
70 }
71 const children = node.children?.map(child => maskTree(child, known))
72 isChanged ||= children?.some((child, index) => child !== node.children?.[index]) ?? false
73 if (!isChanged) return node
74
75 return { ...node, ...(props === undefined ? {} : { props }), ...(children === undefined ? {} : { children }) }
76}
77
78// The tree another hook (or the engine) drew, its strings masked; an engine node is left whole: its props
79// were masked on the way down.
80export function maskTree<T>(node: T, known: readonly string[]): T {
81 if (typeof node === 'string') return maskText(node, known) as T
82 if (!isPlainObject(node) || node.type === 'engine') return node
83
84 return maskElement(node as Node, known) as T
85}
86hooks/move.ts 121 lines1import type { Secret } from './detect'
2import { resolvePath } from './tools'
3
4export type EnvEntry = { name: string; value: string; isNew: boolean }
5
6const CODE_REFS: Record<string, (name: string) => string> = {
7 js: name => `process.env.${name}`,
8 py: name => `os.environ["${name}"]`,
9 dart: name => `Platform.environment['${name}']`,
10 go: name => `os.Getenv("${name}")`,
11 rb: name => `ENV["${name}"]`,
12 php: name => `getenv('${name}')`,
13}
14const EXTENSION_LANG: Record<string, string> = {
15 js: 'js', jsx: 'js', ts: 'js', tsx: 'js', mjs: 'js', cjs: 'js', mts: 'js', cts: 'js',
16 py: 'py', dart: 'dart', go: 'go', rb: 'rb', php: 'php',
17}
18const NEEDS_QUOTES = /[\s#"'$`\\]/
19
20function extensionOf(path: string): string {
21 const dot = path.lastIndexOf('.')
22
23 return dot < 0 ? '' : path.slice(dot + 1).toLowerCase()
24}
25
26function escapeRegExp(text: string): string {
27 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
28}
29
30export function parseEnv(text: string): Map<string, string> {
31 const entries = new Map<string, string>()
32 for (const line of text.split('\n')) {
33 const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
34 if (match === null) continue
35 entries.set(match[1] ?? '', (match[2] ?? '').trim().replace(/^(["'])(.*)\1$/, '$2'))
36 }
37
38 return entries
39}
40
41// One env name per secret: an existing entry holding the same value is reused, a taken name gets a suffix.
42export function planEntries(secrets: Secret[], existing: Map<string, string>): EnvEntry[] {
43 const taken = new Map(existing)
44 const plan: EnvEntry[] = []
45 for (const secret of secrets) {
46 const same = [...taken.entries()].find(([, value]) => value === secret.value)
47 if (same !== undefined) {
48 plan.push({ name: same[0], value: secret.value, isNew: false })
49 continue
50 }
51 let name = secret.envName
52 for (let suffix = 2; taken.has(name); suffix += 1) name = `${secret.envName}_${suffix}`
53 taken.set(name, secret.value)
54 plan.push({ name, value: secret.value, isNew: true })
55 }
56
57 return plan
58}
59
60export function envLines(entries: EnvEntry[]): string {
61 return entries
62 .filter(entry => entry.isNew)
63 .map(entry => `${entry.name}=${NEEDS_QUOTES.test(entry.value) ? JSON.stringify(entry.value) : entry.value}\n`)
64 .join('')
65}
66
67export function appendText(existing: string, addition: string): string {
68 if (addition === '') return existing
69 const glue = existing === '' || existing.endsWith('\n') ? '' : '\n'
70
71 return `${existing}${glue}${addition}`
72}
73
74// Code reads the value from the environment; any other file gets a ${NAME} placeholder.
75export function rewrite(text: string, spec: { path: string; entries: EnvEntry[] }): string {
76 const lang = EXTENSION_LANG[extensionOf(spec.path)]
77 const toRef = lang === undefined ? undefined : CODE_REFS[lang]
78 let out = text
79 for (const entry of spec.entries) {
80 const literal = escapeRegExp(entry.value)
81 if (toRef !== undefined) {
82 out = out.replace(new RegExp(`(["'\`])${literal}\\1`, 'g'), toRef(entry.name))
83 out = out.split(entry.value).join(toRef(entry.name))
84 } else {
85 out = out.split(entry.value).join(`\${${entry.name}}`)
86 }
87 }
88
89 return out
90}
91
92export function hasEnvIgnore(gitignore: string): boolean {
93 return gitignore.split('\n').some(line => /^\/?\.env(\*|\.\*)?\s*$/.test(line.trim()))
94}
95
96// A placeholder in the file is rewritten to the env read too: the same entry, spelled as its token.
97export function withTokens(entries: EnvEntry[], tokens: (string | undefined)[]): EnvEntry[] {
98 const spelled = entries.flatMap((entry, index) => {
99 const token = tokens[index]
100
101 return token === undefined ? [] : [{ ...entry, value: token }]
102 })
103
104 return [...entries, ...spelled]
105}
106
107const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
108
109// The .env goes to the root of the repository holding the file (a worktree's own root, whose `.git` is a file),
110// never the session's root when they differ. Outside any repository: the session's folder if the file is in it.
111export async function envRootOf(spec: { path: string; cwd: string; exists: (path: string) => Promise<boolean> }): Promise<{ root: string; isRepo: boolean }> {
112 const start = dirOf(resolvePath(spec.cwd, spec.path))
113 for (let dir = start; ; dir = dirOf(dir)) {
114 if (await spec.exists(`${dir}/.git`)) return { root: dir, isRepo: true }
115 if (dir === '/') break
116 }
117 const isInSession = spec.cwd !== '' && `${start}/`.startsWith(`${spec.cwd}/`)
118
119 return { root: isInSession ? spec.cwd : start, isRepo: false }
120}
121hooks/placeholders.ts 60 lines1import { fileText, isFileTool } from './tools'
2import { hasTokens, reveal, scrub, sessionVault } from './vault'
3
4// Secret placeholders: a key pasted into a prompt reaches the model as `⟦secret:N⟧`, goes back in only where
5// a Bash command runs, and any echo of it in a tool's output is turned back into the placeholder.
6// Plain decisions only: register.tsx makes every call on `$`.
7
8type Block = { type: string; [field: string]: unknown }
9type Section = { id: string; text: string; scope: 'shared' | 'session' }
10
11const SECTION_ID = 'secret-guard:placeholders'
12// The rows a tool's output reaches the model by.
13const TOOL_DOORS = new Set(['tool-result', 'tool-message'])
14
15const NOTE = [
16 'Some values in this conversation appear as ⟦secret:N⟧ placeholders. Each stands for a real secret the person shared; secret-guard keeps the value out of the chat.',
17 'In a Bash command write the placeholder verbatim, quoted as you would quote the value: it is replaced with the real value just before the command runs, and output that echoes the value shows the placeholder again.',
18 'Never ask the person for the value. Files get the placeholder as written, not the value: code should read the secret from the environment (.env).',
19].join(' ')
20
21export const TOKEN_NOTE = 'secret-guard: the file keeps each ⟦secret:N⟧ placeholder as written; real values are put in only for Bash commands.'
22
23// The system prompt gets one short section while the session holds any placeholder.
24export function withNote<S extends Section>(sections: readonly S[]): (S | Section)[] {
25 const rest = sections.filter(section => section.id !== SECTION_ID)
26
27 return sessionVault.byValue.size === 0 ? rest : [...rest, { id: SECTION_ID, text: NOTE, scope: 'session' }]
28}
29
30// A file write that passes keeps its placeholders, and Claude is told so in one line.
31export function withTokenNote<R extends { deny?: unknown; context?: readonly string[] }>(input: Record<string, unknown>, ran: R): R {
32 if (ran.deny !== undefined || !isFileTool(input) || !hasTokens(fileText(input))) return ran
33
34 return { ...ran, context: [...(ran.context ?? []), TOKEN_NOTE] }
35}
36
37// A Bash command with the real values in; a deny when this session no longer holds one of them.
38export function bashCommand(command: string): { command: string } | { deny: string } {
39 const { text, missing } = reveal(sessionVault, command)
40 if (missing.length === 0) return { command: text }
41
42 return { deny: `secret-guard no longer holds ${missing.join(', ')} (the mod reloaded or the chat was cleared), so the command did not run. Ask the person to paste the secret again.` }
43}
44
45function scrubBlock(block: Block): Block {
46 if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: scrub(sessionVault, block.text) }
47 if (block.type !== 'tool_result') return block
48 const content = block.content
49 if (typeof content === 'string') return { ...block, content: scrub(sessionVault, content) }
50
51 return Array.isArray(content) ? { ...block, content: content.map(item => scrubBlock(item as Block)) } : block
52}
53
54// A tool's output row with every real value turned back into its placeholder; null when there is nothing to do.
55export function scrubbedContent(row: { door: string; content: readonly unknown[] }): Block[] | null {
56 if (sessionVault.byValue.size === 0 || !TOOL_DOORS.has(row.door)) return null
57
58 return row.content.map(block => scrubBlock(block as Block))
59}
60hooks/rec.ts 27 lines1import { maskDeep } from './mask'
2
3// Recording mode: while on, the transcript's rows draw with secrets, env values and e-mail addresses masked.
4// Display only: the stored rows and what the model reads stay as they were.
5
6// The rows recording mode masks, and the props each draws its text from (rewritable ones only: a
7// UserMessage's `origin` and `from` are read-only).
8const SITES: Readonly<Record<string, readonly string[]>> = {
9 UserMessage: ['text'],
10 AssistantMessage: ['text'],
11 ToolUse: ['input', 'output'],
12 ToolResult: ['output'],
13}
14
15// The site's props with their text masked; the same object back when nothing changed.
16export function maskedProps<P extends object>(component: string, props: P, known: readonly string[]): P {
17 let out: Record<string, unknown> = props as Record<string, unknown>
18 for (const key of SITES[component] ?? []) {
19 if (!(key in props)) continue
20 const before = (props as Record<string, unknown>)[key]
21 const masked = maskDeep(before, known)
22 if (masked !== before) out = { ...out, [key]: masked }
23 }
24
25 return out as P
26}
27hooks/status.ts 23 lines1import { words } from './words'
2
3// One quiet line under the prompt, no toast: `REC · 1 secret kept out of the chat`.
4
5const SEPARATOR = ' · '
6// null until the first write: after a reload the engine may still draw the old line, so the first one always goes out.
7let shown: string | undefined | null = null
8
9export function statusText(spec: { isRecording: boolean; keptCount: number }): string | undefined {
10 const said = words()
11 const parts = [spec.isRecording ? said.rec : '', spec.keptCount > 0 ? said.keptOut(spec.keptCount) : '']
12
13 return parts.filter(part => part !== '').join(SEPARATOR) || undefined
14}
15
16// Whether the line must be written: only when its text changes.
17export function isNewStatus(text: string | undefined): boolean {
18 if (text === shown) return false
19 shown = text
20
21 return true
22}
23hooks/tools.ts 50 lines1type Input = Record<string, unknown>
2
3type Edit = { old_string?: unknown; new_string?: unknown }
4
5const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
6
7export function isFileTool(input: Input): boolean {
8 return typeof input.tool === 'string' && FILE_TOOLS.includes(input.tool)
9}
10
11export function pathOf(input: Input): string | null {
12 const path = input.file_path ?? input.notebook_path
13
14 return typeof path === 'string' && path !== '' ? path : null
15}
16
17function editsOf(input: Input): Edit[] {
18 return Array.isArray(input.edits) ? (input.edits as Edit[]) : []
19}
20
21// The text the call would put on disk: only what it adds, never what it replaces.
22export function fileText(input: Input): string {
23 if (input.tool === 'Write') return String(input.content ?? '')
24 if (input.tool === 'Edit') return String(input.new_string ?? '')
25 if (input.tool === 'NotebookEdit') return String(input.new_source ?? '')
26
27 return editsOf(input)
28 .map(edit => String(edit.new_string ?? ''))
29 .join('\n')
30}
31
32export function patchedFields(input: Input, change: (text: string) => string): Input {
33 if (input.tool === 'Write') return { content: change(String(input.content ?? '')) }
34 if (input.tool === 'Edit') return { new_string: change(String(input.new_string ?? '')) }
35 if (input.tool === 'NotebookEdit') return { new_source: change(String(input.new_source ?? '')) }
36
37 return { edits: editsOf(input).map(edit => ({ ...edit, new_string: change(String(edit.new_string ?? '')) })) }
38}
39
40export function resolvePath(base: string, path: string): string {
41 if (path.startsWith('/')) return path
42 const parts = base.split('/')
43 for (const part of path.split('/')) {
44 if (part === '..') parts.pop()
45 else if (part !== '.' && part !== '') parts.push(part)
46 }
47
48 return parts.join('/') || '/'
49}
50hooks/vault.ts 81 lines1import { findSecrets } from './detect'
2import type { Secret } from './detect'
3
4// The secrets the person pasted into a prompt, each behind a placeholder the model sees in its place.
5// Held in this module's memory alone: never `$.store`, `$.state`, a file or a log line. A reload forgets them.
6export type Kept = { token: string; value: string; label: string; envName: string }
7export type Vault = { byValue: Map<string, Kept>; byToken: Map<string, Kept> }
8
9export const TOKEN = /⟦secret:\d+⟧/g
10// A whole PEM block, so the key's body leaves the prompt with its header.
11const PEM = /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g
12
13export const createVault = (): Vault => ({ byValue: new Map(), byToken: new Map() })
14
15export const sessionVault: Vault = createVault()
16
17export function clearVault(vault: Vault): void {
18 vault.byValue.clear()
19 vault.byToken.clear()
20}
21
22function keep(vault: Vault, secret: Pick<Secret, 'value' | 'label' | 'envName'>): Kept {
23 const known = vault.byValue.get(secret.value)
24 if (known !== undefined) return known
25 const kept = { token: `⟦secret:${vault.byValue.size + 1}⟧`, value: secret.value, label: secret.label, envName: secret.envName }
26 vault.byValue.set(kept.value, kept)
27 vault.byToken.set(kept.token, kept)
28
29 return kept
30}
31
32// Longest first, so a value that holds another is replaced whole.
33function replaceAll(text: string, pairs: [string, string][]): string {
34 return [...pairs].sort((a, b) => b[0].length - a[0].length).reduce((out, [from, to]) => out.split(from).join(to), text)
35}
36
37// The prompt with every detected secret behind its placeholder; the same value always gets the same one.
38export function hide(vault: Vault, text: string): string {
39 const pems = [...text.matchAll(PEM)].map(match => ({ value: match[0], label: 'private key', envName: 'PRIVATE_KEY' }))
40 const found = findSecrets(text).filter(secret => secret.kind !== 'private-key')
41 const kept = [...pems, ...found].map(secret => keep(vault, secret))
42
43 return replaceAll(text, kept.map(entry => [entry.value, entry.token]))
44}
45
46// Real values back in place of the placeholders; `missing` names the ones this session no longer holds.
47export function reveal(vault: Vault, text: string): { text: string; missing: string[] } {
48 const missing: string[] = []
49 const out = text.replace(TOKEN, token => {
50 const kept = vault.byToken.get(token)
51 if (kept === undefined) missing.push(token)
52
53 return kept?.value ?? token
54 })
55
56 return { text: out, missing: [...new Set(missing)] }
57}
58
59// Any real value echoed back (a command's output) becomes its placeholder again.
60export function scrub(vault: Vault, text: string): string {
61 if (vault.byValue.size === 0) return text
62
63 return replaceAll(text, [...vault.byValue.values()].map(entry => [entry.value, entry.token]))
64}
65
66export const hasTokens = (text: string): boolean => text.match(TOKEN) !== null
67
68// The placeholders a file write holds, as findings the card shows: the line, and the real value kept for a move.
69export function tokenHits(vault: Vault, text: string): (Secret & { token: string })[] {
70 const hits: (Secret & { token: string })[] = []
71 text.split('\n').forEach((row, index) => {
72 for (const match of row.matchAll(TOKEN)) {
73 const kept = vault.byToken.get(match[0])
74 if (kept === undefined) continue
75 hits.push({ kind: 'placeholder', label: kept.label, value: kept.value, envName: kept.envName, line: index + 1, isMovable: kept.envName !== 'PRIVATE_KEY', token: kept.token })
76 }
77 })
78
79 return hits
80}
81