Holds back a prompt that looks like it carries a secret (API keys, tokens, access-key pairs, private keys, passwords) before it reaches the transcript, puts it…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 348 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BandItem, BandView, Finding } from '../types'
5import { describeFindings, detect, hashText, isBlankTree, mask, testReport, toEnvName, typedName } from './detect'
6import { planExports, replaceWithRefs, resolveRcPath, saveToast } from './zshrc'
7
8type Engine = EngineInterface
9
10const band = atom({ plugin: 'secret-guard', key: 'band' } as const, null)
11const isOff = atom({ plugin: 'secret-guard', key: 'isOff' } as const, false)
12
13/**
14 * Prompts the person sent: Enter at the terminal, Remote Control, and a host app's own box (the desktop
15 * app's Code tab submits through the SDK). Plugins', schedules', peers' and notifications' are left alone.
16 */
17const PERSONAL = new Set(['composer', 'bridge', 'sdk'])
18/** How long Send anyway lets the same text through. */
19const ALLOW_MS = 5 * 60_000
20/** How many name fields the band draws; secrets past them are saved under their suggested names. */
21const SHOWN = 3
22/** A `/secrets` command's record in the transcript, which `/secrets test` would otherwise leave a secret in. */
23const SECRETS_COMMAND = /(?:^|[\s>])\/secrets(?![\w-])/
24
25const USAGE = [
26 'Usage: /secrets test <text> shows what secret-guard would catch in the text (masked)',
27 ' /secrets off | on turns the check off or back on for this session',
28].join('\n')
29
30type Config = { enabled: boolean; extra: RegExp | null; extraError: string | null; zshrcPath: string }
31
32let config: Config = { enabled: true, extra: null, extraError: null, zshrcPath: '~/.zshrc' }
33
34/** The prompt held back last, secrets and all: kept in this module's memory only, never in state or on screen. */
35type Held = { id: number; text: string; findings: Finding[] }
36
37let held: Held | null = null
38/** The fingerprint of the one text Send anyway lets through, and until when. */
39let allowance: { hash: string; until: number } | null = null
40let isSaving = false
41let lastId = 0
42
43/** The band's rows for a held prompt: labels, masked tails and suggested names; never a value. */
44function itemsOf(findings: readonly Finding[]): BandItem[] {
45 return findings.map(finding => ({ label: finding.label, masked: mask(finding.value), name: finding.name, suggested: finding.name }))
46}
47
48/** `Looks like a secret: Secret Access Key …vxrm (masked)`. */
49function headline(items: readonly BandItem[]): string {
50 const listed = items.map(item => `${item.label} ${item.masked}`).join(', ')
51 return `Looks like ${items.length === 1 ? 'a secret' : `${items.length} secrets`}: ${listed} (masked)`
52}
53
54/** The field for the secret at `index`: `name`, then `name-2`, `name-3`. */
55function fieldKey(index: number): string {
56 return index === 0 ? 'name' : `name-${index + 1}`
57}
58
59/** What the person sees where the prompt would have entered: why, and what to do; never a value. */
60function dropReason(findings: readonly Finding[], isRefilled: boolean): string {
61 const what = describeFindings(findings.map(finding => finding.label))
62 const where = isRefilled ? 'It is back in the box: above' : 'Above'
63 return `secret-guard held this prompt back, unsent: it looks like it holds ${what}. ${where} the prompt, choose Save as env var, Send anyway or Edit.`
64}
65
66/** Holds a prompt back: keeps it here, puts its text back in the box, and raises the band and a toast. */
67async function hold($: Engine, text: string, findings: Finding[]): Promise<boolean> {
68 lastId += 1
69 const id = lastId
70 held = { id, text, findings }
71 const filled = await $.prompt.fill({ text, mode: 'replace' }).catch(() => null)
72 const isRefilled = filled?.isFilled === true
73 const view: BandView = { id, items: itemsOf(findings), isRefilled }
74 await update($, band, () => view)
75 $.ui.toast(`secret-guard held your prompt back: it looks like it holds ${describeFindings(findings.map(f => f.label))}.`, {
76 timeoutMs: 8_000,
77 })
78 return isRefilled
79}
80
81/** Takes the band down and forgets the held prompt. */
82async function dismiss($: Engine) {
83 held = null
84 await update($, band, () => null)
85}
86
87/** The name field changed: kept as typed, uppercased, other characters as `_`. */
88async function rename($: Engine, index: number, value: string) {
89 await update($, band, view =>
90 view ? { ...view, items: view.items.map((item, i) => (i === index ? { ...item, name: typedName(value) } : item)) } : null,
91 )
92}
93
94/**
95 * Save as env var: appends `export NAME='value'` for each secret to the shell file (an existing NAME with
96 * the same value is reused, with another value the name gets `_2`), puts the prompt back with `$NAME` in
97 * each secret's place, and says so in a toast that names the variables, never their values.
98 */
99async function save($: Engine) {
100 const current = held
101 const view = await read($, band)
102 if (current === null || view === null || view.id !== current.id || isSaving) {
103 return
104 }
105 isSaving = true
106 try {
107 const where = config.zshrcPath.trim() || '~/.zshrc'
108 const path = resolveRcPath(where, await $.env.get('HOME'))
109 if (path === null) {
110 $.ui.toast(`secret-guard: HOME is not set, so ${where} could not be found. Nothing was saved.`)
111 return
112 }
113 const exists = await $.fs.exists(path).catch(() => false)
114 const before = exists ? await $.fs.read(path).catch(() => null) : ''
115 if (typeof before !== 'string') {
116 $.ui.toast(`secret-guard could not read ${where}, so nothing was saved; the prompt is still in the box.`)
117 return
118 }
119 const wanted = current.findings.map((finding, index) => toEnvName(view.items[index]?.name ?? '') || finding.name)
120 const unique = wanted.map((name, index) => (wanted.indexOf(name) === index ? name : `${name}_${index + 1}`))
121 const { text, plans } = planExports(
122 before,
123 current.findings.map((finding, index) => ({ name: unique[index] ?? finding.name, value: finding.value })),
124 )
125 if (text !== before) {
126 const isWritten = await $.fs.write(path, text).then(
127 () => true,
128 () => false,
129 )
130 if (!isWritten) {
131 $.ui.toast(`secret-guard could not write ${where}, so nothing was saved; the prompt is still in the box.`)
132 return
133 }
134 }
135 const names = plans.map(plan => plan.name)
136 await $.prompt.fill({ text: replaceWithRefs(current.text, current.findings, names), mode: 'replace' }).catch(() => null)
137 await dismiss($)
138 $.ui.toast(saveToast(plans, where), { timeoutMs: 15_000 })
139 } finally {
140 isSaving = false
141 }
142}
143
144/** The name field's Enter: keep the name typed, then save. */
145async function renameAndSave($: Engine, index: number, value: string) {
146 await rename($, index, value)
147 await save($)
148}
149
150/** Send anyway: the next submission of exactly this text, within five minutes, goes through once. */
151async function sendAnyway($: Engine) {
152 const current = held
153 if (current === null) {
154 return
155 }
156 allowance = { hash: hashText(current.text), until: (await $.clock.now()) + ALLOW_MS }
157 await $.prompt.fill({ text: current.text, mode: 'replace' }).catch(() => null)
158 await dismiss($)
159 $.ui.toast('Press Enter to send it as is: secret-guard lets this exact text through once, within 5 minutes.')
160}
161
162/** Whether this text is the one Send anyway allowed, still in time; the allowance is used up either way it matches. */
163async function isAllowed($: Engine, text: string): Promise<boolean> {
164 if (allowance === null) {
165 return false
166 }
167 if ((await $.clock.now()) > allowance.until) {
168 allowance = null
169 return false
170 }
171 if (allowance.hash !== hashText(text)) {
172 return false
173 }
174 allowance = null
175 return true
176}
177
178/** The text with each secret detected in it shown masked: `[masked …vxrm]`. */
179function masked(text: string): string {
180 const findings = detect(text, { extra: config.extra })
181 let out = text
182 for (const finding of [...findings].sort((a, b) => b.start - a.start)) {
183 out = `${out.slice(0, finding.start)}[masked ${mask(finding.value)}]${out.slice(finding.end)}`
184 }
185 return out
186}
187
188/** What `/secrets` alone answers. */
189async function status($: Engine): Promise<string> {
190 const off = await read($, isOff)
191 const state = !config.enabled
192 ? 'secret-guard is turned off in /config (Check prompts for secrets).'
193 : off
194 ? 'secret-guard is off for this session: prompts are sent unchecked.'
195 : 'secret-guard is on: prompts that look like they hold a secret are held back.'
196 const extra = config.extraError ? `\nThe "Also a secret" pattern is not a valid regex and is ignored: ${config.extraError}` : ''
197 return `${state}${extra}\n${USAGE}`
198}
199
200/** What a /secrets record shows when it could not be masked. */
201const WITHHELD = '/secrets … (withheld: secret-guard could not mask this record)'
202
203export const register: Register = (on, options) => {
204 let extra: RegExp | null = null
205 let extraError: string | null = null
206 const source = String(options.extraPatterns ?? '').trim()
207 if (source !== '') {
208 try {
209 extra = new RegExp(source, 'g')
210 } catch (error) {
211 extraError = error instanceof Error ? error.message : String(error)
212 }
213 }
214 config = {
215 enabled: options.enabled !== false,
216 extra,
217 extraError,
218 zshrcPath: String(options.zshrcPath ?? '~/.zshrc'),
219 }
220 held = null
221 allowance = null
222 isSaving = false
223
224 on('session.start', async ($, e, next) => {
225 await $.command.register({
226 name: 'secrets',
227 description: 'Show what secret-guard would catch in a text, or turn it off or on for this session',
228 argumentHint: 'test <text> | off | on',
229 immediate: true,
230 })
231 return next(e)
232 })
233
234 on('prompt.submit', async ($, e, next) => {
235 if (!config.enabled || !PERSONAL.has(e.origin.kind) || (await read($, isOff))) {
236 return next(e)
237 }
238 if (await isAllowed($, e.text)) {
239 await dismiss($)
240 return next(e)
241 }
242 const findings = detect(e.text, { extra: config.extra })
243 if (findings.length === 0) {
244 if (held !== null) {
245 await dismiss($)
246 }
247 return next(e)
248 }
249 const isRefilled = await hold($, e.text, findings)
250 return { drop: dropReason(findings, isRefilled) }
251 })
252
253 // `/secrets test <text>` would leave the text in the command's record: the record keeps it masked.
254 on('session.append', { door: 'command' }, async ($, e, next) => {
255 let isChanged = false
256 const isSecretsText = (block: (typeof e.message.content)[number]) =>
257 block.type === 'text' && typeof block.text === 'string' && SECRETS_COMMAND.test(block.text)
258 let content: typeof e.message.content
259 try {
260 content = e.message.content.map(block => {
261 if (block.type !== 'text' || typeof block.text !== 'string' || !SECRETS_COMMAND.test(block.text)) {
262 return block
263 }
264 const text = masked(block.text)
265 isChanged ||= text !== block.text
266 return { ...block, text }
267 })
268 } catch (error) {
269 // Fail closed: a record that could not be masked keeps no text, rather than the secret it may hold.
270 const message = error instanceof Error ? error.message : String(error)
271 $.ui.log(`secret-guard: masking a /secrets record failed, so its text was withheld: ${message.slice(0, 200)}`, { to: 'debug' })
272 content = e.message.content.map(block => (isSecretsText(block) && block.type === 'text' ? { ...block, text: WITHHELD } : block))
273 isChanged = true
274 }
275 return next(isChanged ? { ...e, message: { ...e.message, content } } : e)
276 })
277
278 on('command.run', { command: 'secrets' }, async ($, e) => {
279 const args = e.args.trim()
280 const verb = (/^\S*/.exec(args)?.[0] ?? '').toLowerCase()
281 if (verb === 'test') {
282 const text = args.slice(verb.length).trim()
283 return { text: text === '' ? USAGE : testReport(detect(text, { extra: config.extra })) }
284 }
285 if (verb === 'off') {
286 await update($, isOff, () => true)
287 await dismiss($)
288 return { text: 'secret-guard is off for this session: prompts are sent unchecked. /secrets on turns it back on.' }
289 }
290 if (verb === 'on') {
291 await update($, isOff, () => false)
292 return {
293 text: config.enabled
294 ? 'secret-guard is on: prompts that look like they hold a secret are held back.'
295 : 'secret-guard is turned off in /config (Check prompts for secrets); turn it on there.',
296 }
297 }
298 return { text: await status($) }
299 })
300
301 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302 const view = await read($, band)
303 if (e.props.hasSurvey || view === null || held?.id !== view.id || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
304 return next(e)
305 }
306 const { Box, Text, Button, Input } = $.ui.resolve(e)
307 const fields = view.items.slice(0, SHOWN)
308 const rest = view.items.length - fields.length
309
310 const guard = (
311 <Box flexDirection="column">
312 <Box flexDirection="row" gap={1}>
313 <Box flexShrink={1}>
314 <Text wrap="truncate-end">{headline(view.items)}</Text>
315 </Box>
316 <Box flexDirection="row" gap={1} flexShrink={0}>
317 <Button key="save" label="Save as env var" variant="primary" onPress={() => save($)} />
318 <Button key="send-anyway" label="Send anyway" onPress={() => sendAnyway($)} />
319 <Button key="edit" label="Edit" onPress={() => dismiss($)} />
320 </Box>
321 </Box>
322 {fields.map((item, index) => (
323 <Input
324 key={fieldKey(index)}
325 label={view.items.length === 1 ? 'Env var name: $' : `${item.label} as $`}
326 value={item.name}
327 placeholder={item.suggested}
328 submitLabel="save"
329 onInput={value => rename($, index, value)}
330 onSubmit={value => renameAndSave($, index, value)}
331 />
332 ))}
333 {rest > 0 ? <Text dimColor>{`+${rest} more, saved under their suggested names`}</Text> : null}
334 </Box>
335 )
336 // Another plugin's band beneath stays, under this one.
337 const below = await next(e)
338 return isBlankTree(below) ? (
339 guard
340 ) : (
341 <Box flexDirection="column">
342 {guard}
343 {below}
344 </Box>
345 )
346 })
347}
348hooks/detect.ts 613 lines1import type { RenderElement } from 'claude-code'
2
3import type { Finding, Separator } from '../types'
4
5/** A known secret shape: what it looks like, what the band calls it, and the env var it suggests. */
6type Shape = {
7 kind: string
8 label: string
9 name: string
10 pattern: RegExp
11 /** The capture group holding the secret, when the match carries more than the secret. */
12 group?: number
13 /** Whether the secret must also pass the value checks a labelled one does (mixed characters, no placeholder). */
14 isLoose?: boolean
15}
16
17/** Shapes the vendors document. Each pattern is global; its boundaries keep it off longer words. */
18const SHAPES: readonly Shape[] = [
19 {
20 kind: 'private-key',
21 label: 'Private key',
22 name: 'PRIVATE_KEY',
23 pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
24 },
25 {
26 kind: 'aws-access-key-id',
27 label: 'AWS access key ID',
28 name: 'AWS_ACCESS_KEY_ID',
29 pattern: /(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/g,
30 },
31 {
32 kind: 'github-token',
33 label: 'GitHub token',
34 name: 'GITHUB_TOKEN',
35 pattern: /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g,
36 },
37 {
38 kind: 'github-token',
39 label: 'GitHub token',
40 name: 'GITHUB_TOKEN',
41 pattern: /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{22,}/g,
42 },
43 {
44 kind: 'anthropic-key',
45 label: 'Anthropic API key',
46 name: 'ANTHROPIC_API_KEY',
47 pattern: /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{20,}/g,
48 },
49 {
50 kind: 'openai-key',
51 label: 'OpenAI API key',
52 name: 'OPENAI_API_KEY',
53 pattern: /(?<![A-Za-z0-9_-])sk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{32,}/g,
54 },
55 {
56 kind: 'slack-token',
57 label: 'Slack token',
58 name: 'SLACK_TOKEN',
59 pattern: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/g,
60 },
61 {
62 kind: 'google-api-key',
63 label: 'Google API key',
64 name: 'GOOGLE_API_KEY',
65 pattern: /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g,
66 },
67 {
68 kind: 'huggingface-token',
69 label: 'Hugging Face token',
70 name: 'HF_TOKEN',
71 pattern: /(?<![A-Za-z0-9_])hf_[A-Za-z0-9]{30,}(?![A-Za-z0-9_])/g,
72 },
73 {
74 kind: 'gitlab-token',
75 label: 'GitLab token',
76 name: 'GITLAB_TOKEN',
77 pattern: /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{20,}/g,
78 },
79 {
80 kind: 'npm-token',
81 label: 'npm token',
82 name: 'NPM_TOKEN',
83 pattern: /(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g,
84 },
85 {
86 kind: 'stripe-key',
87 label: 'Stripe live key',
88 name: 'STRIPE_SECRET_KEY',
89 pattern: /(?<![A-Za-z0-9_])(?:sk|rk)_live_[A-Za-z0-9]{20,}/g,
90 },
91 {
92 kind: 'bearer-token',
93 label: 'Bearer token',
94 name: 'BEARER_TOKEN',
95 pattern: /(?<![A-Za-z0-9])Bearer[ \t]+([A-Za-z0-9._~+/-]{20,}=*)/g,
96 group: 1,
97 isLoose: true,
98 },
99]
100
101/** A password inside a URL (`scheme://user:password@host`); a URL without one is never a secret. */
102const URL_PASSWORD = /(?<![A-Za-z0-9+.-])([a-z][a-z0-9+.-]*):\/\/[^\s:/@]+:([^\s/@]+)@([^\s/:?#]+)/gi
103
104/** The 40-character secret that sits beside an AWS access key ID. */
105const AWS_SECRET = /(?<![A-Za-z0-9/+=])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+=])/g
106
107/** How far from an AWS access key ID its secret is looked for, and how far back a "for <service>" is. */
108const AWS_SECRET_REACH = 300
109const CONTEXT_REACH = 300
110
111/**
112 * The labels a secret is typed after. Longer ones first, so `Secret Access Key` is one label and not
113 * `secret` then `Access Key`. `_` and `-` may join a label to more of an identifier (`DB_PASSWORD`).
114 */
115const LABEL =
116 /(?<![A-Za-z0-9])(wi-?fi[ _-]?passw(?:or)?d|client[ _-]?secret|secret(?:[ _-]?access)?[ _-]?key|access[ _-]?key(?:[ _-]?id)?|api[ _-]?key|passw(?:or)?d|pass|secret|token)(?![A-Za-z0-9])/gi
117
118/** What may stand between a label and its separator: `for the guest network`, `of the router` (group 1). */
119const QUALIFIER = String.raw`(?:[ \t]+(?:for|of|on|at|in|to)[ \t]+((?:[^\s:=.,;!?]+[ \t]+){0,3}?[^\s:=.,;!?]+))?`
120
121/** `label: value`, `label = value`, `"label": "value"`, `label := value`, `label => value`. */
122const SAME_LINE = new RegExp(`${QUALIFIER}["'\`]?[ \\t]*(?:\\*\\*|__)?[ \\t]*(?::=|=>|:(?!:)|=(?!=))[ \\t]*(?:\\*\\*|__)?[ \\t]*(\\S+)`, 'iy')
123
124/** `label is value`, `label for the guest network is value`. */
125const IS_FORM = new RegExp(`${QUALIFIER}[ \\t]+(?:is|was)[ \\t]*:?[ \\t]+(\\S+)`, 'iy')
126
127/** The label ending its line (`for <what>` and a colon allowed) and the value alone on the next one: the two-line paste. */
128const NEXT_LINE = new RegExp(`${QUALIFIER}["'\`]?[ \\t]*:?[ \\t]*\\r?\\n[ \\t]*(\\S+)(?=[ \\t]*(?:\\r?\\n|$))`, 'iy')
129
130/** The last part of an identifier label that says it names something about a secret, not the secret. */
131const NOT_A_SECRET_SUFFIX = new Set([
132 'length', 'len', 'min', 'max', 'count', 'type', 'url', 'uri', 'endpoint', 'path', 'file', 'dir', 'name',
133 'field', 'label', 'hint', 'policy', 'expiry', 'expires', 'expiration', 'ttl', 'timeout', 'header', 'prefix',
134 'regex', 'pattern', 'rule', 'rules', 'limit', 'limits', 'size', 'format', 'mode', 'enabled', 'required',
135 'hash', 'env', 'var', 'variable', 'location', 'store', 'manager', 'provider', 'source', 'version',
136])
137
138/** The env var name each label stands for. */
139const LABEL_NAMES: readonly [RegExp, string][] = [
140 [/^wi-?fi[ _-]?passw(?:or)?d$/i, 'WIFI_PASSWORD'],
141 [/^client[ _-]?secret$/i, 'CLIENT_SECRET'],
142 [/^secret[ _-]?access[ _-]?key$/i, 'SECRET_ACCESS_KEY'],
143 [/^secret[ _-]?key$/i, 'SECRET_KEY'],
144 [/^access[ _-]?key[ _-]?id$/i, 'ACCESS_KEY_ID'],
145 [/^access[ _-]?key$/i, 'ACCESS_KEY'],
146 [/^api[ _-]?key$/i, 'API_KEY'],
147 [/^passw(?:or)?d$|^pass$/i, 'PASSWORD'],
148 [/^secret$/i, 'SECRET'],
149 [/^token$/i, 'TOKEN'],
150]
151
152/** Words that never name a service: "my", "the", "for now", "at least", ... */
153const STOP_WORDS = new Set([
154 'a', 'an', 'the', 'my', 'your', 'our', 'their', 'his', 'her', 'its', 'this', 'that', 'these', 'those', 'new',
155 'old', 'current', 'same', 'other', 'and', 'or', 'with', 'use', 'using', 'here', 'there', 'is', 'are', 'was',
156 'be', 'for', 'from', 'on', 'at', 'to', 'in', 'of', 'it', 'me', 'you', 'us', 'them', 'now', 'later', 'also',
157 'both', 'all', 'each', 'any', 'some', 'following', 'below', 'above', 'example', 'instance', 'least', 'most',
158 'once', 'temporary', 'temp', 'default', 'personal', 'secret', 'token', 'password', 'key', 'keys', 'api',
159 'access', 'please', 'set', 'get', 'got', 'just', 'then', 'what', 'which', 'whose', 'one', 'two', 'it\'s',
160 'login', 'log', 'sign', 'signing', 'free', 'real', 'actual', 'correct', 'right', 'wrong', 'today',
161])
162
163/** Second-level parts of a domain that are not the service's name. */
164const DOMAIN_NOISE = new Set(['www', 'api', 'app', 'apps', 'console', 'platform', 'dashboard', 'portal', 'co', 'com', 'org', 'net', 'ac', 'gov', 'edu', 'io'])
165
166const PASSWORD_LABEL = /passw(?:or)?d|^pass$|[ _-]pass$/i
167
168type Candidate = Finding & {
169 /** 2: named by an identifier (`DB_PASSWORD=`); 1: by a label with a service before it; 0: by a bare label or a shape. */
170 strength: number
171 isShape: boolean
172}
173
174/** What `detect` can be given beside the text: a person's own extra pattern. */
175export type DetectOptions = { extra?: RegExp | null }
176
177/** The secret's character classes present: lowercase, uppercase, digits, anything else. */
178export function characterClasses(value: string): number {
179 return [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(re => re.test(value)).length
180}
181
182/** `$NAME`, `${NAME}`, `$(cmd)`, `%NAME%`, `{{ secrets.X }}`, `process.env.X`, `os.environ[...]`, `getenv(...)`. */
183export function isReference(value: string): boolean {
184 return (
185 value.startsWith('$') ||
186 /^%[A-Za-z_][A-Za-z0-9_]*%/.test(value) ||
187 /^\{\{.*\}\}$/.test(value) ||
188 /(?:^|[^A-Za-z0-9_])(?:process\.env|import\.meta\.env|os\.environ|os\.getenv|getenv|System\.getenv|ENV\[|env\(|secrets\.)/.test(value)
189 )
190}
191
192/** `xxxx`, `***`, `...`, `<your-key>`, `[token]`, `your-key-here`, `changeme`, `aaaaaaaa`. */
193export function isPlaceholder(value: string): boolean {
194 return (
195 /xxxx|XXXX|\*{3,}|\.{3,}|…|•{3,}/.test(value) ||
196 /^<[^>]*>$|^\[[^\]]*\]$|^\{[^}]*\}$/.test(value) ||
197 /your[-_ ]?(?:own[-_ ]?)?(?:api[-_ ]?|access[-_ ]?|secret[-_ ]?)?(?:key|token|secret|pass(?:word)?)/i.test(value) ||
198 /(?:key|token|secret|password)[-_]?here|change[-_]?me|placeholder|redacted|replace[-_]?me|insert[-_]?your/i.test(value) ||
199 new Set(value).size <= 2
200 )
201}
202
203/** A URL: an address, never a secret by itself (a password inside one is found as its own shape). */
204export function isUrl(value: string): boolean {
205 return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^www\./i.test(value)
206}
207
208/** `/etc/x`, `~/.ssh/id_rsa`, `./secrets.json`, `C:\x`, `config/secrets.yml`; a base64 value has `+` or `=` far more often. */
209export function isPath(value: string): boolean {
210 if (/^[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]{1,6}$/.test(value)) {
211 return true
212 }
213 return /^(?:~|\.{1,2})?\/|^[A-Za-z]:[\\/]/.test(value) && !/[+=]/.test(value)
214}
215
216/** Code, not a value: `getToken()`, `config.apiKey`, `access_token`, `accessToken`, `API_KEY`, `Required`. */
217export function isCodeLike(value: string): boolean {
218 return (
219 /^[A-Za-z_$][\w$.]*[ \t]*[([{]/.test(value) ||
220 /^(?:true|false|null|none|nil|undefined)$/i.test(value) ||
221 /^[a-z]+(?:_[a-z]+)+$/.test(value) ||
222 /^[a-z]+(?:[A-Z][a-z]+)+$/.test(value) ||
223 /^(?:[A-Z][a-z]+)+$/.test(value) ||
224 /^[A-Z]+(?:_[A-Z0-9]+)*$/.test(value) ||
225 (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) && value.split('.').every(part => part.length <= 24))
226 )
227}
228
229/**
230 * Whether a value typed after a label reads as a secret rather than prose, code or a pointer to one:
231 * at least 8 characters, no reference, placeholder, URL, path or code, and mixed characters (digits
232 * alone only after a password label).
233 */
234export function isSecretValue(value: string, label = ''): boolean {
235 if (value.length < 8 || /\s/.test(value)) {
236 return false
237 }
238 if (isReference(value) || isPlaceholder(value) || isUrl(value) || isPath(value) || isCodeLike(value)) {
239 return false
240 }
241 if (/^\d+$/.test(value)) {
242 return PASSWORD_LABEL.test(label)
243 }
244 return characterClasses(value) >= 2
245}
246
247/** `FOO bar-baz.qux` → `FOO_BAR_BAZ_QUX`: uppercase, `[A-Z][A-Z0-9_]*`; `''` when nothing is left. */
248export function toEnvName(text: string): string {
249 const name = text
250 .toUpperCase()
251 .replace(/[^A-Z0-9]+/g, '_')
252 .replace(/^_+|_+$/g, '')
253 if (name === '') {
254 return ''
255 }
256 return /^[A-Z]/.test(name) ? name : `SECRET_${name}`
257}
258
259/** The name field as the person types it: uppercased, other characters as `_`, nothing trimmed yet. */
260export function typedName(text: string): string {
261 return text.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
262}
263
264/** `opendatalab.com` → `opendatalab`, `api.openai.com` → `openai`; a plain word is kept. */
265export function serviceOf(word: string): string {
266 const parts = word.split('.').filter(Boolean)
267 if (parts.length < 2) {
268 return word
269 }
270 const core = parts.slice(0, -1).filter(part => !DOMAIN_NOISE.has(part.toLowerCase()))
271 return core.at(-1) ?? parts[0] ?? word
272}
273
274/** The env var name part a label stands for: `Access Key ID` → `ACCESS_KEY_ID`. */
275export function labelName(label: string): string {
276 return LABEL_NAMES.find(([re]) => re.test(label))?.[1] ?? toEnvName(label)
277}
278
279/** The service word just before a label on its line (`KIT api key`, `OpenDataLab Access Key ID`), or null. */
280function wordBefore(text: string, at: number): string | null {
281 const lineStart = Math.max(text.lastIndexOf('\n', at - 1) + 1, at - 80)
282 const match = /([A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9])(?:'s)?[ \t]+$/.exec(text.slice(lineStart, at))
283 const word = match?.[1]
284 return word && !STOP_WORDS.has(word.toLowerCase()) ? word : null
285}
286
287/** The service a nearby "for opendatalab", "from huggingface", "on staging", "at example.com" names, or null. */
288function contextWord(text: string, at: number): string | null {
289 const window = text.slice(Math.max(0, at - CONTEXT_REACH), at)
290 const words = [...window.matchAll(/(?<![A-Za-z0-9])(?:for|from|on|at)[ \t]+(?:(?:the|my|our|your)[ \t]+)?([A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9])/gi)]
291 .map(match => match[1] ?? '')
292 .filter(word => word !== '' && !STOP_WORDS.has(word.toLowerCase()))
293 return words.at(-1) ?? null
294}
295
296/**
297 * The service prefix for a secret whose label starts at `at`: the word before the label, else the label's own
298 * `for <what>`, else a "for <service>" up to 300 characters back; `''` for none.
299 */
300export function servicePrefix(text: string, at: number, qualifier: string | null = null): string {
301 const word = wordBefore(text, at) ?? qualifier ?? contextWord(text, at)
302 return word ? toEnvName(serviceOf(word)) : ''
303}
304
305/** Joins a service prefix to a name part, not repeating it: `OPENDATALAB` + `ACCESS_KEY_ID`. */
306function prefixed(prefix: string, name: string): string {
307 return prefix === '' || name === prefix || name.startsWith(`${prefix}_`) ? name : `${prefix}_${name}`
308}
309
310/** The ranges of fenced code blocks (``` or ~~~), each from its opening fence to its closing one or the end. */
311export function fencedRanges(text: string): [number, number][] {
312 const ranges: [number, number][] = []
313 let open: { at: number; fence: string } | null = null
314 let at = 0
315 for (const line of text.split('\n')) {
316 const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line)?.[1]
317 if (fence && open === null) {
318 open = { at, fence: fence.slice(0, 3) }
319 } else if (fence && open !== null && fence.startsWith(open.fence)) {
320 ranges.push([open.at, at + line.length])
321 open = null
322 }
323 at += line.length + 1
324 }
325 if (open !== null) {
326 ranges.push([open.at, text.length])
327 }
328 return ranges
329}
330
331/** Quotes, a wrapping tag and trailing punctuation around a value: `"abc",` and `abc</td>` → `abc`; the trimmed offsets. */
332function trimValue(text: string, start: number, end: number): [number, number] {
333 let s = start
334 let e = end
335 for (let isTrimming = true; isTrimming; ) {
336 isTrimming = false
337 while (s < e && /["'`]/.test(text[s] ?? '')) {
338 s += 1
339 }
340 const tag = /<\/?[A-Za-z][\w-]*>$/.exec(text.slice(s, e))
341 if (tag && tag.index > 0) {
342 e -= tag[0].length
343 isTrimming = true
344 }
345 while (e > s && /[.,;:!?)\]}"'`]/.test(text[e - 1] ?? '')) {
346 e -= 1
347 isTrimming = true
348 }
349 }
350 return [s, e]
351}
352
353/** The token around a label joined by `_` or `-` (`DB_PASSWORD`, `x-api-key`): its offsets. */
354function identifierAround(text: string, start: number, end: number): [number, number] {
355 let s = start
356 let e = end
357 while (s > 0 && /[A-Za-z0-9_-]/.test(text[s - 1] ?? '')) {
358 s -= 1
359 }
360 while (e < text.length && /[A-Za-z0-9_-]/.test(text[e] ?? '')) {
361 e += 1
362 }
363 return [s, e]
364}
365
366/** The value a label ending at `end` is tied to, by `:`/`=`, `is`, or the next line, with any `for <what>` between; or null. */
367function valueAfter(
368 text: string,
369 end: number,
370): { start: number; end: number; separator: Separator; qualifier: string } | null {
371 const forms: [RegExp, Separator][] = [
372 [SAME_LINE, 'colon'],
373 [IS_FORM, 'is'],
374 [NEXT_LINE, 'newline'],
375 ]
376 for (const [re, separator] of forms) {
377 re.lastIndex = end
378 const match = re.exec(text)
379 const value = match?.[2]
380 if (match && value !== undefined) {
381 const stop = match.index + match[0].length
382 return { start: stop - value.length, end: stop, separator, qualifier: match[1] ?? '' }
383 }
384 }
385 return null
386}
387
388/** The first word of a `for the guest network` qualifier that can name a service (`guest`), or null. */
389function qualifierWord(qualifier: string): string | null {
390 return qualifier.split(/[ \t]+/).find(word => /^[A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9]$/.test(word) && !STOP_WORDS.has(word.toLowerCase())) ?? null
391}
392
393/** Secrets typed after a label: `password: …`, `Secret Access Key\n…`, `the wifi password is …`, `DB_PASSWORD=…`. */
394function labelled(text: string): Candidate[] {
395 const found: Candidate[] = []
396 for (const match of text.matchAll(LABEL)) {
397 const label = match[1] ?? ''
398 const labelStart = match.index ?? 0
399 const [idStart, idEnd] = identifierAround(text, labelStart, labelStart + match[0].length)
400 const identifier = text.slice(idStart, idEnd)
401 const isIdentifier = identifier !== label
402 if (isIdentifier) {
403 const last = identifier.split(/[_-]/).at(-1)?.toLowerCase() ?? ''
404 if (NOT_A_SECRET_SUFFIX.has(last) && !LABEL_NAMES.some(([re]) => re.test(last))) {
405 continue
406 }
407 }
408 const tied = valueAfter(text, idEnd)
409 if (!tied) {
410 continue
411 }
412 const [start, end] = trimValue(text, tied.start, tied.end)
413 const value = text.slice(start, end)
414 if (!isSecretValue(value, isIdentifier ? identifier : label)) {
415 continue
416 }
417 if (isIdentifier) {
418 const name = toEnvName(identifier)
419 found.push({ kind: 'labelled', label: identifier, value, start, end, name, strength: 2, isShape: false })
420 continue
421 }
422 const prefix = servicePrefix(text, labelStart, qualifierWord(tied.qualifier))
423 const name = prefixed(prefix, labelName(label))
424 found.push({ kind: 'labelled', label, value, start, end, name, strength: prefix ? 1 : 0, isShape: false })
425 }
426 return found
427}
428
429/** The env var a URL's password suggests: `POSTGRES_PASSWORD`, or the host's name for http(s). */
430function urlPasswordName(scheme: string, host: string): string {
431 const base = /^(?:https?|ftp|sftp|ssh|wss?)$/i.test(scheme) ? serviceOf(host) : scheme.replace(/ql$/i, '').replace(/\+srv$/i, '')
432 return prefixed(toEnvName(base), 'PASSWORD')
433}
434
435/** Secrets of a known shape, the AWS secret beside an access key ID, and passwords inside URLs. */
436function shaped(text: string): Candidate[] {
437 const found: Candidate[] = []
438 for (const shape of SHAPES) {
439 for (const match of text.matchAll(shape.pattern)) {
440 const value = shape.group === undefined ? match[0] : (match[shape.group] ?? '')
441 const start = (match.index ?? 0) + (shape.group === undefined ? 0 : match[0].lastIndexOf(value))
442 if (value === '' || isPlaceholder(value.replace(/^[A-Za-z]+[_-]/, '')) || (shape.isLoose && !isSecretValue(value))) {
443 continue
444 }
445 found.push({ kind: shape.kind, label: shape.label, value, start, end: start + value.length, name: shape.name, strength: 0, isShape: true })
446 }
447 }
448 for (const key of found.filter(one => one.kind === 'aws-access-key-id')) {
449 const from = Math.max(0, key.start - AWS_SECRET_REACH)
450 const window = text.slice(from, key.end + AWS_SECRET_REACH)
451 for (const match of window.matchAll(AWS_SECRET)) {
452 const value = match[0]
453 const start = from + (match.index ?? 0)
454 const isKey = start < key.end && start + value.length > key.start
455 if (isKey || /^[0-9a-f]{40}$/i.test(value) || !/[a-z]/.test(value) || !/[A-Z]/.test(value) || isPlaceholder(value)) {
456 continue
457 }
458 found.push({
459 kind: 'aws-secret-access-key',
460 label: 'AWS secret access key',
461 value,
462 start,
463 end: start + value.length,
464 name: 'AWS_SECRET_ACCESS_KEY',
465 strength: 0,
466 isShape: true,
467 })
468 }
469 }
470 for (const match of text.matchAll(URL_PASSWORD)) {
471 const value = match[2] ?? ''
472 const start = (match.index ?? 0) + match[0].lastIndexOf(`:${value}@`) + 1
473 if (!isSecretValue(value, 'password')) {
474 continue
475 }
476 const name = urlPasswordName(match[1] ?? '', match[3] ?? '')
477 found.push({ kind: 'url-password', label: 'Password in a URL', value, start, end: start + value.length, name, strength: 0, isShape: true })
478 }
479 return found
480}
481
482/** Matches of the person's own pattern (group 1 when it has one), named by a nearby service when one is. */
483function custom(text: string, extra: RegExp): Candidate[] {
484 const found: Candidate[] = []
485 const pattern = new RegExp(extra.source, `${extra.flags.replace(/[gy]/g, '')}g`)
486 for (const match of text.matchAll(pattern)) {
487 const value = match[1] ?? match[0]
488 if (value.length < 4 || isReference(value) || isPlaceholder(value)) {
489 continue
490 }
491 const start = (match.index ?? 0) + Math.max(0, match[0].indexOf(value))
492 const prefix = servicePrefix(text, match.index ?? 0)
493 const name = prefix ? `${prefix}_SECRET` : ''
494 found.push({ kind: 'custom', label: 'Custom pattern', value, start, end: start + value.length, name, strength: prefix ? 1 : 0, isShape: true })
495 }
496 return found
497}
498
499/** One finding per stretch of text: a shape's range and kind, a label's naming when it names more. */
500function merge(candidates: Candidate[]): Candidate[] {
501 const sorted = [...candidates].sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start))
502 const kept: Candidate[] = []
503 for (const next of sorted) {
504 const index = kept.findIndex(one => next.start < one.end && one.start < next.end)
505 const prior = index === -1 ? undefined : kept[index]
506 if (prior === undefined) {
507 kept.push(next)
508 continue
509 }
510 const shape = prior.isShape ? prior : next.isShape ? next : prior.end - prior.start >= next.end - next.start ? prior : next
511 const namer = [prior, next].sort((a, b) => b.strength - a.strength)[0] ?? prior
512 const isNamedByLabel = namer.strength > 0
513 kept[index] = {
514 ...shape,
515 label: isNamedByLabel ? namer.label : shape.label,
516 name: isNamedByLabel ? namer.name : shape.name || namer.name,
517 strength: Math.max(prior.strength, next.strength),
518 }
519 }
520 return kept
521}
522
523/** Unnamed findings become `SECRET_1`, `SECRET_2`, ...; a name used twice gets `_2`, `_3`. */
524function nameAll(findings: Candidate[]): Finding[] {
525 const used = new Set<string>()
526 let unnamed = 0
527 return findings.map(({ strength: _strength, isShape: _isShape, ...finding }) => {
528 let base = finding.name
529 if (base === '') {
530 unnamed += 1
531 base = `SECRET_${unnamed}`
532 }
533 let name = base
534 for (let n = 2; used.has(name); n += 1) {
535 name = `${base}_${n}`
536 }
537 used.add(name)
538 return { ...finding, name }
539 })
540}
541
542/**
543 * The secrets in a prompt, in order: known shapes, values typed after a label, the person's own pattern.
544 * Values in fenced code that say `EXAMPLE` are examples, not secrets.
545 */
546export function detect(text: string, options: DetectOptions = {}): Finding[] {
547 const fences = fencedRanges(text)
548 const candidates = [...shaped(text), ...labelled(text), ...(options.extra ? custom(text, options.extra) : [])]
549 const real = candidates.filter(
550 one => !(one.value.includes('EXAMPLE') && fences.some(([from, to]) => one.start >= from && one.end <= to)),
551 )
552 return nameAll(merge(real))
553}
554
555/** `…vxrm`: the last characters of a secret, never more than a fifth of it; a private key shows none. */
556export function mask(value: string): string {
557 if (value.startsWith('-----BEGIN')) {
558 return '(key block)'
559 }
560 const shown = Math.min(4, Math.floor(value.length / 5))
561 return shown > 0 ? `…${value.slice(-shown)}` : '…'
562}
563
564/** `Secret Access Key`, `Access Key ID and Secret Access Key`, `3 secrets`: never a value. */
565export function describeFindings(labels: readonly string[]): string {
566 if (labels.length === 1) {
567 return `a secret (${labels[0]})`
568 }
569 if (labels.length === 2) {
570 return `2 secrets (${labels[0]} and ${labels[1]})`
571 }
572 return `${labels.length} secrets`
573}
574
575/** What `/secrets test` answers: what would be caught, masked, and the names it would suggest. */
576export function testReport(findings: readonly Finding[]): string {
577 if (findings.length === 0) {
578 return 'secret-guard finds no secret in that text: it would be sent as is.'
579 }
580 const count = findings.length === 1 ? '1 secret' : `${findings.length} secrets`
581 return [
582 `secret-guard would hold that prompt back: ${count}.`,
583 ...findings.map(finding => ` ${finding.label} ${mask(finding.value)} → $${finding.name}`),
584 ].join('\n')
585}
586
587/** Zero-width and other invisible characters, CRLF and the ends trimmed: what a refilled box sends back. */
588function normalized(text: string): string {
589 return text.replace(/\p{Cf}/gu, '').replace(/\r\n/g, '\n').trim()
590}
591
592/** A fingerprint of a prompt's text (two 32-bit FNV-style hashes and the length), so the text itself need not be kept. */
593export function hashText(text: string): string {
594 const s = normalized(text)
595 let a = 0x811c9dc5
596 let b = 0x9747b28c
597 for (let i = 0; i < s.length; i += 1) {
598 const c = s.charCodeAt(i)
599 a = Math.imul(a ^ c, 0x01000193) >>> 0
600 b = Math.imul(b ^ c, 0x5bd1e995) >>> 0
601 b = (b ^ (b >>> 13)) >>> 0
602 }
603 return `${s.length}:${a.toString(16)}:${b.toString(16)}`
604}
605
606/** True for what the engine draws when no plugin draws the band, or an empty Box. */
607export function isBlankTree(tree: RenderElement): boolean {
608 if (tree.type === 'engine') {
609 return true
610 }
611 return tree.type === 'Box' && (tree.children ?? []).length === 0
612}
613hooks/zshrc.ts 148 lines1/** A value in single quotes for zsh: every `'` closed, escaped and reopened (`it's` → `'it'\''s'`). */
2export function shellQuote(value: string): string {
3 return `'${value.replace(/'/g, `'\\''`)}'`
4}
5
6/** The line Save appends: `export NAME='value'`. */
7export function exportLine(name: string, value: string): string {
8 return `export ${name}=${shellQuote(value)}`
9}
10
11/**
12 * One shell word from `at`, its quoting undone: `'…'` as written, `"…"` with `\"`, `\\`, `\$` and
13 * `` \` `` unescaped, a bare `\x` as `x`; it ends at unquoted whitespace, `;`, `&` or `|`.
14 */
15export function readShellWord(text: string, at: number): string {
16 let out = ''
17 let i = at
18 while (i < text.length) {
19 const c = text[i] ?? ''
20 if (c === "'") {
21 const close = text.indexOf("'", i + 1)
22 out += close === -1 ? text.slice(i + 1) : text.slice(i + 1, close)
23 i = close === -1 ? text.length : close + 1
24 } else if (c === '"') {
25 i += 1
26 while (i < text.length && text[i] !== '"') {
27 const d = text[i] ?? ''
28 const after = text[i + 1] ?? ''
29 if (d === '\\' && '"\\$`\n'.includes(after) && after !== '') {
30 out += after === '\n' ? '' : after
31 i += 2
32 } else {
33 out += d
34 i += 1
35 }
36 }
37 i += 1
38 } else if (c === '\\') {
39 const after = text[i + 1] ?? ''
40 out += after === '\n' ? '' : after
41 i += 2
42 } else if (/[\s;&|]/.test(c)) {
43 break
44 } else {
45 out += c
46 i += 1
47 }
48 }
49 return out
50}
51
52/** The value `export NAME=…` gives NAME in a shell file (the last such line wins), or undefined when none does. */
53export function exportedValue(rc: string, name: string): string | undefined {
54 const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
55 const line = new RegExp(`^[ \\t]*(?:export|typeset[ \\t]+-x|declare[ \\t]+-x)[ \\t]+${escaped}=`, 'gm')
56 let value: string | undefined
57 for (const match of rc.matchAll(line)) {
58 value = readShellWord(rc, (match.index ?? 0) + match[0].length)
59 }
60 return value
61}
62
63/** What saving one secret does to the shell file: its new text, the name used, and whether it was there already. */
64export type ExportPlan = { text: string; name: string; isReused: boolean }
65
66/**
67 * Saves `value` as `name` in the shell file's text: reused when `name` already exports that value,
68 * appended as `export NAME='value'` when `name` is not exported, and as `NAME_2` (`_3`, …) when `name`
69 * already holds a different value.
70 */
71export function planExport(rc: string, name: string, value: string): ExportPlan {
72 for (let n = 1; n < 100; n += 1) {
73 const candidate = n === 1 ? name : `${name}_${n}`
74 const existing = exportedValue(rc, candidate)
75 if (existing === value) {
76 return { text: rc, name: candidate, isReused: true }
77 }
78 if (existing === undefined) {
79 const lead = rc === '' || rc.endsWith('\n') ? rc : `${rc}\n`
80 return { text: `${lead}${exportLine(candidate, value)}\n`, name: candidate, isReused: false }
81 }
82 }
83 throw new Error(`${name} and its _2 to _99 are all taken in the shell file`)
84}
85
86/** Several secrets saved in turn: the final text, and the name each one ended up under. */
87export function planExports(rc: string, secrets: readonly { name: string; value: string }[]): { text: string; plans: ExportPlan[] } {
88 let text = rc
89 const plans: ExportPlan[] = []
90 for (const secret of secrets) {
91 const plan = planExport(text, secret.name, secret.value)
92 text = plan.text
93 plans.push(plan)
94 }
95 return { text, plans }
96}
97
98/** The shell file's absolute path: `~` and a relative path are under HOME; null when HOME is needed and unset. */
99export function resolveRcPath(configured: string, home: string | undefined): string | null {
100 const path = configured.trim() || '~/.zshrc'
101 if (path.startsWith('/')) {
102 return path
103 }
104 if (!home) {
105 return null
106 }
107 const base = home.replace(/\/+$/, '')
108 if (path === '~') {
109 return base
110 }
111 return path.startsWith('~/') ? `${base}/${path.slice(2)}` : `${base}/${path}`
112}
113
114/** The prompt with each secret replaced by `$NAME` (`${NAME}` when a word character follows). */
115export function replaceWithRefs(text: string, spans: readonly { start: number; end: number }[], names: readonly string[]): string {
116 const order = spans.map((span, index) => ({ ...span, name: names[index] ?? '' })).sort((a, b) => b.start - a.start)
117 let out = text
118 for (const { start, end, name } of order) {
119 const ref = /[A-Za-z0-9_]/.test(out[end] ?? '') ? `\${${name}}` : `$${name}`
120 out = out.slice(0, start) + ref + out.slice(end)
121 }
122 return out
123}
124
125/** `A`, `A and B`, `A, B and C`. */
126function listed(items: readonly string[]): string {
127 return items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`
128}
129
130/**
131 * The toast after Save: what was saved or already there, what the prompt now says, and how to check.
132 * Names only, never a value.
133 */
134export function saveToast(plans: readonly ExportPlan[], where: string): string {
135 const saved = plans.filter(plan => !plan.isReused).map(plan => plan.name)
136 const reused = plans.filter(plan => plan.isReused).map(plan => plan.name)
137 const names = plans.map(plan => plan.name)
138 const parts = [
139 ...(saved.length > 0 ? [`Saved ${listed(saved)} to ${where}`] : []),
140 ...(reused.length > 0 ? [`${listed(reused)} ${reused.length === 1 ? 'was' : 'were'} already in ${where}`] : []),
141 ]
142 const first = names[0] ?? 'NAME'
143 return (
144 `${parts.join('; ')}; the prompt now says ${listed(names.map(name => `$${name}`))}. ` +
145 `New shells see ${names.length === 1 ? 'it' : 'them'} (Claude can run: zsh -ic 'echo \${#${first}}' to check).`
146 )
147}
148types/index.d.ts 44 lines1/** How a labelled value was tied to its label: `:` or `=`, ` is `, or the value alone on the next line. */
2export type Separator = 'colon' | 'is' | 'newline'
3
4/** One secret found in a prompt: where it sits, what it is, and the env var name suggested for it. */
5export type Finding = {
6 /** A known shape's id (`aws-access-key-id`, `github-token`, ...), `labelled` or `custom`. */
7 kind: string
8 /** What the band calls it: the label as typed (`Secret Access Key`) or the shape's name (`GitHub token`). */
9 label: string
10 /** The secret itself. Kept in the module's memory only: never in state, a toast, a log or the status line. */
11 value: string
12 /** Offsets of `value` in the prompt's text. */
13 start: number
14 end: number
15 /** The env var name suggested for it, `[A-Z][A-Z0-9_]*`; unique within one prompt. */
16 name: string
17}
18
19/** One secret as the band shows it: its label, its masked tail and the name to save it under; never its value. */
20export type BandItem = {
21 label: string
22 /** `…vxrm`: the last few characters, at most a fifth of the value. */
23 masked: string
24 /** The name in the band's field, as the person is typing it. */
25 name: string
26 /** The suggested name, used when the field is left empty. */
27 suggested: string
28}
29
30/** What the band above the prompt shows for the prompt held back last. */
31export type BandView = {
32 /** Which held prompt this is; the band draws only while the module still holds that prompt. */
33 id: number
34 items: BandItem[]
35 /** Whether the box took the text back; false where no box could (headless). */
36 isRefilled: boolean
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'secret-guard': { band: BandView | null; isOff: boolean }
42 }
43}
44