SLOPSHOPPER

secret-guard

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…

newrowsguardcommandstatusprompt
★ 2v?MITupdated 2026-10-13partalemre/MODZ/mods/secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-guard ⎿ secret-guard: secret-guard off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: REC
README

secret-guard

Stops API keys and tokens before they land in a file or in git, and offers to move them into .env.

Install

/plugin marketplace add partalemre/MODZ
/plugin install secret-guard@modz

Or in one step: /plugin install secret-guard --marketplace partalemre/MODZ.

What it checks

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-----.
  • Supabase service_role JWTs. The payload is decoded locally; anon keys pass.
  • Any *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).

What you see

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:

  • Move keys to .env (recommended) appends 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.
  • Allow once runs this one call unchanged.
  • Cancel, dismissing the question, or typing under Other denies the call with a clear reason.

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.

Secrets you paste into a prompt

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.

  • Bash: right before a command runs, each placeholder is replaced with the real value. Any echo of a value in a tool's output is turned back into its placeholder before Claude reads it.
  • Files: a placeholder is never replaced inside a file. A write that holds one shows the card (the placeholder itself, not the value's first characters); Move keys to .env writes the real value to .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.
  • A quiet line under the prompt says 1 secret kept out of the chat; no toast.
  • The values live only in the mod's memory for this session: never in $.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.

Recording mode

/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.

Commands

  • /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).

Settings

There are no plugin options.

Limits

  • It fails open: if the scan itself crashes, the call goes through and a debug line is logged, because a mod error must never block a turn.
  • Only the text a call adds is scanned (Edit's new string, not the old one).
  • Shell commands other than git add and git commit (for example echo KEY=... > config.js) are not scanned.
  • A placeholder is replaced in Bash commands only, as raw text: Claude quotes it as it would the value (a $ inside double quotes would still expand). Other tools get the placeholder.
  • The screen still draws a tool's own output record as it came (for example a command that prints the real value); recording mode masks it, and Claude only reads the placeholder.
  • Recording mode masks what it can see in one string: a key split across differently colored pieces of a row may stay visible.
  • 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).

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 20 files
hooks/register.tsx 261 lines
1import { 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}
261
hooks/card.tsx 85 lines
1import { 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}
85
hooks/detect.ts 134 lines
1import { 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}
134
hooks/findings.ts 60 lines
1import { 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.`
60
hooks/git.ts 118 lines
1import { 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}
118
hooks/mask.ts 86 lines
1import { 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}
86
hooks/move.ts 121 lines
1import 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}
121
hooks/placeholders.ts 60 lines
1import { 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}
60
hooks/rec.ts 27 lines
1import { 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}
27
hooks/status.ts 23 lines
1import { 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}
23
hooks/tools.ts 50 lines
1type 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}
50
hooks/vault.ts 81 lines
1import { 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