Holds a prompt that carries a credential shape and offers to send it redacted, telling Claude where the value really lives.

A Claude Code mod that stops a credential you paste into a prompt from reaching the model. When a prompt carries a credential shape (a Shopify token, an API key, a private key, a password line), the prompt is held and you choose what happens:
<REDACTED:shopify#1>, and Claude gets a hidden note naming the kind of credential and where it really lives (an environment variable, op read op://..., wrangler secret ...), with an instruction never to ask you to paste it again.If nobody answers (you dismiss the dialog, or the session is headless, such as claude -p), the prompt is sent redacted. Messages you did not type (another session, a background task, a schedule, a channel, a plugin) are redacted with no dialog, because a dialog nobody started would stall the session.
The value itself never appears anywhere the mod writes: not in the dialog, a toast, a log line, an error or storage. You see the kind of credential only.
Both are prompt.submit hooks that decide before the prompt goes on, and both carry a fail-closed .catch: if a hook fails before deciding (a bug, a timeout, a re-entry), the prompt is held with secret-guard: prompt held, possible credential detected, never sent raw. claude plugin validate lists each as a gating hook with .catch.
| Registration | Matcher | What it does |
|---|---|---|
| Your own prompts | origin.kind is composer, bridge or sdk; text matches the candidate prefilter | Scans, asks, then redacts, sends or holds as above |
| Everything else | origin.kind is any other kind, including kinds a later Claude Code build adds; text matches the candidate prefilter | Scans and redacts with no dialog |
The candidate prefilter is a cheap case-insensitive regex of the literal every detector needs (a vendor prefix, PRIVATE KEY, authorization, cookie, pass, token, api key and so on), so most prompts never run the scan.
Other calls it makes: $.ui.ask (the dialog), $.ui.toast (a one-line notice), $.prompt.fill with $.clock.after (the draft after Cancel), and $.ui.copy (the draft goes to the clipboard when the session has no prompt box, as in the desktop app). It keeps no state and makes no network or model calls.
Every pattern is anchored on a prefix or a nearby label and has a bounded length. Bare hex or base64 never matches on its own, so commit SHAs, UUIDs, sha256 digests and 32-hex bundle paths pass untouched.
| Kind (placeholder) | Shape |
|---|---|
private-key | -----BEGIN ... PRIVATE KEY-----; the whole block to its footer is redacted, or for a truncated paste the header line plus every base64 line after it |
shopify | shpat_, shpss_, shpca_, shppa_ + 32 hex |
anthropic | sk-ant- + 20 or more of [A-Za-z0-9_-] |
openai | sk-proj- + 20 or more; a bare sk- + 20 or more only after the start, whitespace, a quote or =/:, and only with a 16+ letters-and-digits run (so task-, desk-, risk- and URL slugs never match) |
github | ghp_, gho_, ghu_, ghs_, ghr_ + 36; github_pat_ + 82 |
aws | AKIA/ASIA + 16 [A-Z0-9]; a 40-character secret access key within 20 characters after a secret_access_key label |
google | AIza + 35 |
slack | xoxa-, xoxb-, xoxp-, xoxr-, xoxs- + 10 or more, with a digit |
stripe | sk_live_ or rk_live_ + 24 or more |
authorization | Authorization: Bearer (or token, Bot) + 20 or more characters with Shannon entropy above 3.5; Authorization: Basic + a value that decodes to user:password |
cookie | a Cookie: or Set-Cookie: line holding a name=value pair whose value is 20 or more characters; the whole header value is redacted |
api-token | a Cloudflare-style 40-character [A-Za-z0-9_-] token, with a letter and a digit and not all hex, within 20 characters after a label such as token, api key, access key or auth key |
password | password/passwd (or a pass key such as DB_PASS=) followed by : or = and a value of 8 or more characters, on a line shorter than 200 characters |
The password rule skips values that stand for a secret rather than being one: <placeholder>, ${VAR}, $VAR, {{var}}, xxxxxxxx, ********, changeme, type names (string, Optional[str]), and code references (os.environ[...], getpass(), config.db.password, db_password). Inside a fenced code block that looks like a config template (one of its values is a placeholder), documentation dummies such as example-pass-123 are skipped too.
The same value twice shares one placeholder; distinct values of one kind are numbered #1, #2. Overlapping hits (a Bearer header whose value is also an Anthropic key) merge, so no part of a value survives. After redaction the text is scanned again until nothing matches.
Each kind has two userConfig fields, shown as rows in /config and stored under pluginConfigs["secret-guard"] in settings:
scan<Kind> (boolean, default true): set it to false to stop scanning for that kind. Anything other than an explicit false keeps the kind on.hint<Kind> (string): where that credential lives, told to Claude in the note. A hint that is empty, multi-line, longer than 200 characters or itself credential-shaped is ignored in favour of the default. Never put a credential in a hint.| Kind | Fields | Default hint |
|---|---|---|
| private key | scanPrivateKey, hintPrivateKey | the key file on disk (for example ~/.ssh/id_ed25519), referenced by path |
| Shopify | scanShopify, hintShopify | wrangler secret SHOPIFY_ADMIN_TOKEN |
| Anthropic | scanAnthropic, hintAnthropic | the ANTHROPIC_API_KEY environment variable |
| OpenAI-style | scanOpenai, hintOpenai | the OPENAI_API_KEY environment variable |
| GitHub | scanGithub, hintGithub | gh auth token |
| AWS | scanAws, hintAws | the AWS profile in ~/.aws/credentials (aws configure) |
| Google API | scanGoogle, hintGoogle | the GOOGLE_API_KEY environment variable |
| Slack | scanSlack, hintSlack | the SLACK_BOT_TOKEN environment variable |
| Stripe | scanStripe, hintStripe | wrangler secret STRIPE_SECRET_KEY |
| Authorization header | scanAuthorization, hintAuthorization | the API_TOKEN environment variable |
| session cookie | scanCookie, hintCookie | a fresh login in the browser (cookies are never pasted) |
| API token | scanApiToken, hintApiToken | wrangler secret CLOUDFLARE_API_TOKEN |
| password | scanPassword, hintPassword | op read "op://Private/<item>/password" |
From a terminal, for one session:
claude --plugin-dir /absolute/path/to/mods/secret-guard
Where no flag can be given (a session the desktop app or an SDK host starts), name the folder in CLAUDE_CODE_PLUGIN_DIRS, in the process environment or in the env block of ~/.claude/settings.json.
To check it:
claude plugin validate /absolute/path/to/mods/secret-guard
claude plugin test /absolute/path/to/mods/secret-guard
The tests generate every credential fixture at run time from a seeded generator, so the repository never holds a credential-shaped string.
@, context other hooks attach, and anything the model reads through a tool (cat .env, command output) are not. A session.append redactor would be the sibling mod for tool output.UserPromptSubmit settings hooks; the toast says so.: or = is not caught. In the other direction, an obvious documentation dummy such as DB_PASSWORD=mypassword outside a template fence still raises the dialog, because it cannot be told from a real weak password.hooks/register.tsx 232 lines1/**
2 * secret-guard: holds a prompt that carries a credential shape before it
3 * reaches the model, and offers to send it redacted, with a note telling the
4 * model where the value really lives.
5 *
6 * Two `prompt.submit` registrations, both failing closed:
7 *
8 * - The person's own prompts (composer, Remote Control bridge, SDK host) get
9 * a dialog: Redact and reference / Send anyway / Cancel. A dialog nobody
10 * answers (dismissed, headless, refused) falls back to redaction so the flow
11 * continues safely; an explicit Cancel holds the prompt and puts the draft
12 * back, redacted.
13 * - Every other origin (peers, task notifications, schedules, channels,
14 * plugins, and any origin a later build adds) is redacted with no dialog: a
15 * dialog nobody started would stall the session.
16 *
17 * Each registration decides before it calls `next`, and its `.catch` drops the
18 * prompt when the hook fails before deciding, so a bug, a timeout or a
19 * re-entry never lets the value through. No matched value ever reaches a
20 * toast, the dialog, a log line, an error or `$.store`: only kinds and
21 * placeholders do. The mod keeps no state.
22 */
23import type { EngineInterface, PluginOptions, PromptFilled, PromptSubmitInput, Register } from 'claude-code'
24
25import { CANDIDATE_RE, KINDS, hasCredential, kindsPhrase, redact } from './scan'
26import type { KindId, Redaction } from './scan'
27
28const REDACT = 'Redact and reference'
29const SEND = 'Send anyway'
30const CANCEL = 'Cancel'
31const CHOICES = [REDACT, SEND, CANCEL] as const
32
33/** What the `.catch` answers when a hook fails before deciding: the prompt is held. */
34const HOLD = 'secret-guard: prompt held, possible credential detected'
35
36/**
37 * The person's own origins, and their exact complement. Regexes rather than a
38 * list: the complement then covers an origin kind a later build adds, and the
39 * declarations type a list of kinds per origin variant, which refuses a
40 * several-kind list there (the engine itself takes either form).
41 */
42const ATTENDED = /^(?:composer|bridge|sdk)$/
43const UNATTENDED = /^(?!(?:composer|bridge|sdk)$)/
44
45const REFILL_DELAY_MS = 150
46const REFILL_RETRIES = 3
47const MAX_HINT_LENGTH = 200
48
49type Settings = {
50 readonly enabled: ReadonlySet<KindId>
51 readonly hints: ReadonlyMap<KindId, string>
52}
53
54const ALL_KINDS: ReadonlySet<KindId> = new Set(KINDS.map(kind => kind.id))
55
56/**
57 * Reads the userConfig fields. Anything but an explicit `false` keeps a kind
58 * on, so a missing or mistyped option never switches the guard off; a hint
59 * that is empty, multi-line, too long or itself credential-shaped falls back
60 * to the default, so a setting can't smuggle a value into the model's context.
61 */
62function readSettings(options: PluginOptions): Settings {
63 const enabled = new Set<KindId>()
64 const hints = new Map<KindId, string>()
65 for (const kind of KINDS) {
66 if (options[`scan${kind.option}`] !== false) enabled.add(kind.id)
67 const hint = options[`hint${kind.option}`]
68 const isUsable =
69 typeof hint === 'string' &&
70 hint.trim() !== '' &&
71 hint.length <= MAX_HINT_LENGTH &&
72 !/[\r\n]/.test(hint) &&
73 !hasCredential(hint, ALL_KINDS)
74 hints.set(kind.id, isUsable ? hint.trim() : kind.hint)
75 }
76 return { enabled, hints }
77}
78
79const labelOf = (id: KindId) => KINDS.find(kind => kind.id === id)?.label ?? id
80
81/** Who sent a message the person did not type, in words for the note and the toast. */
82function sourceOf(origin: PromptSubmitInput['origin']): string {
83 switch (origin.kind) {
84 case 'peer':
85 case 'peer-send-message':
86 return 'a message from another Claude session'
87 case 'task-notification':
88 return 'a background task notification'
89 case 'scheduled-trigger':
90 return 'a scheduled prompt'
91 case 'channel':
92 return 'a channel message'
93 case 'coordinator':
94 case 'projects-relay':
95 return 'a coordinating session message'
96 case 'observer':
97 case 'observer-activity':
98 return 'an observer report'
99 case 'auto-continuation':
100 return 'an automatic follow-up'
101 case 'plugin':
102 return 'a plugin-submitted prompt'
103 default:
104 return `a ${origin.kind} message`
105 }
106}
107
108/** The context entry only the model reads: what was removed, and where each value really lives. */
109function referenceNote(found: Redaction, settings: Settings, source: string): string {
110 const count = found.findings.length
111 return [
112 `secret-guard: ${source} held ${count} credential-shaped value${count === 1 ? '' : 's'}, removed before it reached you and replaced with placeholders:`,
113 ...found.findings.map(
114 finding =>
115 `- ${finding.placeholder}: a ${labelOf(finding.kind)} credential. Where it lives: ${settings.hints.get(finding.kind)}`,
116 ),
117 'Treat each placeholder as a reference, never as the value itself, and never write it into a file or command as if it were one. When a command needs a value, read it at run time from where it lives (an environment variable, `op read op://...`, `wrangler secret ...`) without printing it. Never ask the person to paste a credential into the chat again; if you do not know where one lives, ask for the name of the variable or secret store, not the value.',
118 ].join('\n')
119}
120
121function withReference(e: PromptSubmitInput, found: Redaction, settings: Settings, source: string): PromptSubmitInput {
122 return { ...e, text: found.redacted, context: [...(e.context ?? []), referenceNote(found, settings, source)] }
123}
124
125/** Toasts never decide anything, so one that fails must not fail the guard. */
126function notify($: EngineInterface, text: string): void {
127 try {
128 $.ui.toast(text, { timeoutMs: 6000 })
129 } catch {
130 // The decision already stands; a missing toast costs only the notice.
131 }
132}
133
134const plural = (count: number, one: string, many: string) => (count === 1 ? one : many)
135
136/**
137 * Puts the held draft, redacted, back in the prompt box once the drop has
138 * landed (a timer, so the engine's own handling of the drop comes first).
139 * A dialog still closing gets a few retries; a session with no prompt box (the
140 * desktop app) gets the clipboard instead, and the person is told either way.
141 */
142function refillLater($: EngineInterface, text: string, attempt = 0): void {
143 $.clock.after(REFILL_DELAY_MS, () => {
144 void refill($, text, attempt)
145 })
146}
147
148export type RefillStep = 'done' | 'retry' | 'clipboard' | 'give-up'
149
150/**
151 * What to do after a refill attempt. Only the engine sets a refusal's cause
152 * (a hook's own refusal arrives with none), so an absent cause is treated as
153 * unknown and gives up rather than guessing that no prompt box exists.
154 */
155export function nextRefillStep(filled: Pick<PromptFilled, 'isFilled' | 'refusal'>, attempt: number): RefillStep {
156 if (filled.isFilled) return 'done'
157 if (filled.refusal === 'dialog') return attempt < REFILL_RETRIES ? 'retry' : 'give-up'
158 if (filled.refusal === 'no_composer') return 'clipboard'
159 return 'give-up'
160}
161
162const NOT_PUT_BACK = 'secret-guard: the held draft could not be put back in the prompt box.'
163
164async function refill($: EngineInterface, text: string, attempt: number): Promise<void> {
165 try {
166 const step = nextRefillStep(await $.prompt.fill({ text, mode: 'replace' }), attempt)
167 if (step === 'done') return
168 if (step === 'retry') return refillLater($, text, attempt + 1)
169 if (step === 'clipboard') {
170 const copied = await $.ui.copy({ text })
171 notify($, copied.isCopied ? 'secret-guard: the held draft, value redacted, is on your clipboard.' : NOT_PUT_BACK)
172 return
173 }
174 notify($, NOT_PUT_BACK)
175 } catch {
176 notify($, NOT_PUT_BACK)
177 }
178}
179
180export const register: Register = (on, options) => {
181 const settings = readSettings(options)
182
183 on('prompt.submit', { origin: { kind: ATTENDED }, text: CANDIDATE_RE }, async ($, e, next) => {
184 const found = redact(e.text, settings.enabled)
185 if (found.findings.length === 0) return next(e)
186 const kinds = kindsPhrase(found.kinds)
187 const count = found.findings.length
188
189 let answer: string | undefined
190 try {
191 answer = await $.ui.ask(`secret-guard: a possible ${kinds} credential is in your prompt. How should it go?`, CHOICES)
192 } catch {
193 // Dismissed, headless (-p) or refused: nobody answered, so send it redacted.
194 answer = undefined
195 }
196
197 if (answer === undefined || answer === REDACT) {
198 notify(
199 $,
200 answer === undefined
201 ? `secret-guard: no answer, so the possible ${kinds} ${plural(count, 'credential was', 'credentials were')} redacted before sending.`
202 : `secret-guard: redacted ${count} possible ${kinds} ${plural(count, 'credential', 'credentials')}; Claude was told where to read ${plural(count, 'it', 'them')}.`,
203 )
204 return next(withReference(e, found, settings, "the person's prompt"))
205 }
206
207 if (answer === SEND) {
208 notify(
209 $,
210 `secret-guard: sent as typed. The ${kinds} value is now in this session's transcript; rotate it if it must stay secret.`,
211 )
212 return next(e)
213 }
214
215 // Cancel, or words typed under "Other": hold it and give the draft back, redacted.
216 refillLater($, found.redacted)
217 return { drop: `Held: possible ${kinds} credential in prompt. The draft goes back in the prompt box with the value redacted.` }
218 }).catch(($, e, next) => (next.called ? next(e) : { drop: HOLD }))
219
220 on('prompt.submit', { origin: { kind: UNATTENDED }, text: CANDIDATE_RE }, ($, e, next) => {
221 const found = redact(e.text, settings.enabled)
222 if (found.findings.length === 0) return next(e)
223 const source = sourceOf(e.origin)
224 const count = found.findings.length
225 notify(
226 $,
227 `secret-guard: redacted ${count} possible ${kindsPhrase(found.kinds)} ${plural(count, 'credential', 'credentials')} from ${source}.`,
228 )
229 return next(withReference(e, found, settings, source))
230 }).catch(($, e, next) => (next.called ? next(e) : { drop: HOLD }))
231}
232hooks/scan.ts 465 lines1/**
2 * secret-guard's detector: finds credential-shaped values in a prompt and
3 * builds the redacted text. Pure functions: no `$`, no state, no I/O.
4 *
5 * Why this shape: every pattern is anchored on a vendor prefix or a nearby
6 * label and bounded in length, and bare hex or base64 is never a match on its
7 * own, so the commit SHAs, UUIDs, digests and 32-hex bundle paths pasted every
8 * day never raise the dialog (a guard that cries wolf gets switched off).
9 * Each hit covers the value itself, never its label, and overlapping hits are
10 * merged before redaction so no part of a value survives.
11 *
12 * Security invariant: nothing this module returns or throws carries a matched
13 * value. Only kinds, counts and placeholders leave it; values exist solely as
14 * local strings while the redacted text is built.
15 */
16
17export type KindId =
18 | 'private-key'
19 | 'shopify'
20 | 'anthropic'
21 | 'openai'
22 | 'github'
23 | 'aws'
24 | 'google'
25 | 'slack'
26 | 'stripe'
27 | 'authorization'
28 | 'cookie'
29 | 'api-token'
30 | 'password'
31
32export type Kind = {
33 readonly id: KindId
34 /** What the person and the model are told it is ("a possible <label> credential"). */
35 readonly label: string
36 /** userConfig suffix: `scan<option>` (boolean) and `hint<option>` (string). */
37 readonly option: string
38 /** Where the value should be read from instead, unless the person configures another. */
39 readonly hint: string
40}
41
42/**
43 * Every kind, in merge priority order: when two hits cover the same span the
44 * earlier kind names the placeholder (a vendor shape beats a generic one).
45 * plugin.json's userConfig carries one `scan*` and one `hint*` field per row,
46 * with these hints as its defaults.
47 */
48export const KINDS: readonly Kind[] = [
49 { id: 'private-key', label: 'private key', option: 'PrivateKey', hint: 'the key file on disk (for example ~/.ssh/id_ed25519), referenced by path' },
50 { id: 'shopify', label: 'Shopify', option: 'Shopify', hint: 'wrangler secret SHOPIFY_ADMIN_TOKEN' },
51 { id: 'anthropic', label: 'Anthropic', option: 'Anthropic', hint: 'the ANTHROPIC_API_KEY environment variable' },
52 { id: 'openai', label: 'OpenAI-style', option: 'Openai', hint: 'the OPENAI_API_KEY environment variable' },
53 { id: 'github', label: 'GitHub', option: 'Github', hint: 'gh auth token' },
54 { id: 'aws', label: 'AWS', option: 'Aws', hint: 'the AWS profile in ~/.aws/credentials (aws configure)' },
55 { id: 'google', label: 'Google API', option: 'Google', hint: 'the GOOGLE_API_KEY environment variable' },
56 { id: 'slack', label: 'Slack', option: 'Slack', hint: 'the SLACK_BOT_TOKEN environment variable' },
57 { id: 'stripe', label: 'Stripe', option: 'Stripe', hint: 'wrangler secret STRIPE_SECRET_KEY' },
58 { id: 'authorization', label: 'Authorization header', option: 'Authorization', hint: 'the API_TOKEN environment variable' },
59 { id: 'cookie', label: 'session cookie', option: 'Cookie', hint: 'a fresh login in the browser (cookies are never pasted)' },
60 { id: 'api-token', label: 'API token', option: 'ApiToken', hint: 'wrangler secret CLOUDFLARE_API_TOKEN' },
61 { id: 'password', label: 'password', option: 'Password', hint: 'op read "op://Private/<item>/password"' },
62]
63
64const PRIORITY: ReadonlyMap<KindId, number> = new Map(KINDS.map((kind, index) => [kind.id, index]))
65
66/**
67 * The prefilter the registrations match `e.text` against, so the hook only
68 * runs on prompts that could hold a hit. It MUST be a superset of every
69 * detector below: each alternative is a literal that every match of some
70 * detector contains (the tests hold every positive fixture to it). No `g`
71 * flag: the engine tests it repeatedly and a global regex carries `lastIndex`.
72 */
73export const CANDIDATE_RE =
74 /shp(?:at|ss|ca|pa)_|sk-|[sr]k_live_|gh[pousr]_|github_pat_|AKIA|ASIA|AIza|xox[abprs]-|PRIVATE KEY|authorization|cookie|pass|token|api[ _.-]?key|access[ _.-]?key|auth[ _.-]?key/i
75
76type Span = { readonly kind: KindId; readonly start: number; readonly end: number }
77
78type Detector = { readonly kind: KindId; readonly find: (text: string, fences: Fences) => Span[] }
79
80/** Whole-match spans of a global regex, kept where `accept` (if given) agrees. */
81function wholeMatches(kind: KindId, pattern: RegExp, accept?: (value: string) => boolean) {
82 return (text: string): Span[] => {
83 const spans: Span[] = []
84 for (const match of text.matchAll(pattern)) {
85 const value = match[0]
86 if (accept && !accept(value)) continue
87 spans.push({ kind, start: match.index, end: match.index + value.length })
88 }
89 return spans
90 }
91}
92
93/** Spans of one capture group (the value after a label), from the `d` flag's indices. */
94function groupMatches(
95 kind: KindId,
96 pattern: RegExp,
97 group: number,
98 accept?: (value: string, match: RegExpMatchArray) => boolean,
99) {
100 return (text: string): Span[] => {
101 const spans: Span[] = []
102 for (const match of text.matchAll(pattern)) {
103 const value = match[group]
104 const at = match.indices?.[group]
105 if (value === undefined || at === undefined) continue
106 if (accept && !accept(value, match)) continue
107 spans.push({ kind, start: at[0], end: at[1] })
108 }
109 return spans
110 }
111}
112
113/** Shannon entropy in bits per character. */
114export function entropy(value: string): number {
115 if (value.length === 0) return 0
116 const counts = new Map<string, number>()
117 for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
118 let bits = 0
119 for (const count of counts.values()) {
120 const p = count / value.length
121 bits -= p * Math.log2(p)
122 }
123 return bits
124}
125
126const hasDigit = (value: string) => /[0-9]/.test(value)
127const hasLetter = (value: string) => /[A-Za-z]/.test(value)
128const isHexOnly = (value: string) => /^[0-9a-f]+$/i.test(value)
129
130/** Longest run of letters and digits: a real key body has one; a hyphenated slug does not. */
131function longestAlnumRun(value: string): number {
132 let longest = 0
133 for (const run of value.split(/[^A-Za-z0-9]+/)) longest = Math.max(longest, run.length)
134 return longest
135}
136
137/** Basic credentials decode to `user:password`; anything else is not one. */
138function isBasicCredential(value: string): boolean {
139 if (typeof atob !== 'function') return true // cannot check: treat it as a credential
140 try {
141 return atob(value).includes(':')
142 } catch {
143 return false
144 }
145}
146
147// Fenced code blocks, for the password rule's "config template" exemption.
148
149type Fence = { readonly start: number; readonly end: number; readonly isTemplate: boolean }
150type Fences = { readonly at: (offset: number) => Fence | undefined }
151
152const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})/
153
154/**
155 * A value that stands for a secret rather than being one: `<placeholder>`,
156 * `${VAR}`, `$VAR`, `{{var}}`, `%VAR%`, `xxxxxxxx`, `********`, `changeme`, a
157 * type name, a code reference (`os.environ[...]`, `getpass()`), or one of this
158 * mod's own `<REDACTED:...>` placeholders.
159 */
160export function isPlaceholder(raw: string): boolean {
161 const value = raw.trim().replace(/^["'`]|["'`,;]+$/g, '')
162 if (value.length === 0) return true
163 if (/^<[^<>]*>$/.test(value) || value.includes('<REDACTED:')) return true
164 if (/^\$\{[^}]*\}$/.test(value) || /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value)) return true
165 if (/^\{\{[^}]*\}\}$/.test(value) || /^%[A-Za-z_][A-Za-z0-9_]*%$/.test(value)) return true
166 if (/^(.)\1+$/.test(value)) return true
167 if (/change[-_ ]?me|placeholder|^your[-_ ]|\[redacted\]|^redacted$|x{4,}|\*{3,}/i.test(value)) return true
168 if (
169 /^(?:secret|password|passwd|pass|null|nil|none|undefined|true|false|string|str|number|int|bool|boolean|required|optional|hidden|masked|empty|example|sample|dummy|todo|tbd)$/i.test(
170 value,
171 )
172 )
173 return true
174 // A code reference (`os.environ["X"]`, `getpass()`, `config.db.password`) names where the value lives.
175 if (/^[A-Za-z_$][\w$.]*\s*[[(]/.test(value)) return true
176 if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value)) return true
177 // So does a bare snake_case or camelCase identifier (`password=db_password`, `"pass": didPass`).
178 if (/^[A-Za-z]+(?:_[A-Za-z]+)+$/.test(value) || /^[a-z]+(?:[A-Z][a-z]+)+$/.test(value)) return true
179 return false
180}
181
182/** Values documentation uses inside a template fence (`my-example-pass`); never skipped outside one. */
183const DOC_DUMMY = /example|sample|dummy|demo|change.?me|placeholder|your[-_]|fixme|todo|test/i
184
185// Greedy capture, trimmed by the caller: a lazy `(.+?)\s*$` is quadratic on a long run of spaces.
186const ASSIGNED_VALUE = /^\s*[A-Za-z_][\w.-]*["']?\s*[:=]\s*(.*)$/
187
188/**
189 * The fenced blocks of `text`, each marked as a config template when one of
190 * its `KEY=value` / `key: value` lines holds a placeholder value.
191 */
192function findFences(text: string): Fences {
193 const fences: Fence[] = []
194 let open: { start: number; marker: string; hasPlaceholder: boolean } | undefined
195 let offset = 0
196 for (const line of text.split('\n')) {
197 const lineEnd = offset + line.length
198 const marker = FENCE_LINE.exec(line)?.[1]
199 if (open === undefined) {
200 if (marker) open = { start: offset, marker, hasPlaceholder: false }
201 } else if (marker && marker[0] === open.marker[0] && marker.length >= open.marker.length && line.trim() === marker) {
202 fences.push({ start: open.start, end: lineEnd, isTemplate: open.hasPlaceholder })
203 open = undefined
204 } else if (!open.hasPlaceholder) {
205 const value = ASSIGNED_VALUE.exec(line)?.[1]?.trim()
206 if (value !== undefined && value !== '' && isPlaceholder(value)) open.hasPlaceholder = true
207 }
208 offset = lineEnd + 1
209 }
210 if (open) fences.push({ start: open.start, end: text.length, isTemplate: open.hasPlaceholder })
211 // Fences are disjoint and in order, so a binary search finds the one holding an offset.
212 const at = (offset: number): Fence | undefined => {
213 let low = 0
214 let high = fences.length - 1
215 while (low <= high) {
216 const middle = (low + high) >> 1
217 const fence = fences[middle]!
218 if (offset < fence.start) high = middle - 1
219 else if (offset > fence.end) low = middle + 1
220 else return fence
221 }
222 return undefined
223 }
224 return { at }
225}
226
227// Private keys: the whole block goes, header to footer, never the header alone.
228
229const KEY_BEGIN = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g
230const KEY_END = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g
231
232/** A line of a key's body: base64 (long, or with a digit, `+`, `/` or `=`, so a lone word is not one) or a PEM header. */
233function isKeyBodyLine(line: string): boolean {
234 if (/^[A-Za-z-]+: /.test(line)) return true
235 return /^[A-Za-z0-9+/=]+$/.test(line) && (line.length >= 16 || /[0-9+/=]/.test(line))
236}
237
238/**
239 * Where a block with no footer (a truncated paste) ends: the rest of the
240 * header's line, then every body line, blank lines between them allowed. A
241 * line walk, not a regex, so a long line costs one pass.
242 */
243function truncatedKeyEnd(text: string, headerEnd: number): number {
244 const lineEnd = (from: number) => {
245 const newline = text.indexOf('\n', from)
246 return newline === -1 ? text.length : newline
247 }
248 let end = lineEnd(headerEnd)
249 let cursor = end
250 while (cursor < text.length) {
251 const next = lineEnd(cursor + 1)
252 const line = text.slice(cursor + 1, next).trim()
253 if (line !== '' && !isKeyBodyLine(line)) break
254 if (line !== '') end = next
255 cursor = next
256 }
257 return end
258}
259
260function privateKeys(text: string): Span[] {
261 const ends = [...text.matchAll(KEY_END)].map(match => ({ start: match.index, end: match.index + match[0].length }))
262 const spans: Span[] = []
263 let nextEnd = 0
264 for (const begin of text.matchAll(KEY_BEGIN)) {
265 const headerEnd = begin.index + begin[0].length
266 while (nextEnd < ends.length && ends[nextEnd]!.start < headerEnd) nextEnd += 1
267 const footer = ends[nextEnd]
268 if (footer) {
269 spans.push({ kind: 'private-key', start: begin.index, end: footer.end })
270 continue
271 }
272 spans.push({ kind: 'private-key', start: begin.index, end: truncatedKeyEnd(text, headerEnd) })
273 }
274 return spans
275}
276
277// Passwords: `password: <value>` / `PASSWORD=<value>` on a short line.
278
279// A bare `pass` key counts only as part of a key (`DB_PASS=`, `"pass":`, `pass:` opening a
280// line); after a word it is prose (`this pass:`, `tests pass:`, Go's `--- PASS: TestA`).
281const PASSWORD =
282 /(?:pass(?:word|wd)|(?:(?<=[_.\-"'`])|(?<=(?:^|\n)[ \t]*(?:export[ \t]+)?))pass(?![A-Za-z]))["'`]?[ \t]*[:=][ \t]*(?:"([^"\r\n]{8,})"|'([^'\r\n]{8,})'|`([^`\r\n]{8,})`|([^\s"'`]{8,}))/gid
283const MAX_PASSWORD_LINE = 200
284
285function passwords(text: string, fences: Fences): Span[] {
286 const spans: Span[] = []
287 for (const match of text.matchAll(PASSWORD)) {
288 const group = [1, 2, 3, 4].find(index => match[index] !== undefined)
289 const at = group === undefined ? undefined : match.indices?.[group]
290 if (group === undefined || at === undefined) continue
291 const start = at[0]
292 const trailing = group === 4 ? (/[,;)\]}]*$/.exec(match[4]!)?.[0].length ?? 0) : 0
293 const end = at[1] - trailing
294 const value = text.slice(start, end)
295 if (value.length < 8 || isPlaceholder(value)) continue
296 // A bare `pass` key also heads test tallies (`pass: 1234/1234`); there a value needs a letter.
297 if (!/^pass(?:word|wd)/i.test(match[0]) && !hasLetter(value)) continue
298 const lineStart = text.lastIndexOf('\n', match.index) + 1
299 const newline = text.indexOf('\n', match.index)
300 const lineEnd = newline === -1 ? text.length : newline
301 if (lineEnd - lineStart >= MAX_PASSWORD_LINE) continue
302 if (fences.at(match.index)?.isTemplate && DOC_DUMMY.test(value)) continue
303 spans.push({ kind: 'password', start, end })
304 }
305 return spans
306}
307
308const DETECTORS: readonly Detector[] = [
309 { kind: 'private-key', find: privateKeys },
310 { kind: 'shopify', find: wholeMatches('shopify', /(?<![A-Za-z0-9_])shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}(?![A-Za-z0-9_])/g) },
311 { kind: 'anthropic', find: wholeMatches('anthropic', /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}/g) },
312 { kind: 'openai', find: wholeMatches('openai', /(?<![A-Za-z0-9])sk-proj-[A-Za-z0-9_-]{20,}/g) },
313 {
314 // The bare `sk-` form: only after the start, whitespace, a quote or an
315 // assignment, so task-/desk-/risk- and URL slugs never qualify, and only
316 // with a long letters-and-digits run, which a hyphenated slug lacks.
317 kind: 'openai',
318 find: wholeMatches(
319 'openai',
320 /(?<=^|[\s'"`=:,;(\[{<>|])sk-(?!ant-|proj-)[A-Za-z0-9_-]{20,}/g,
321 value => longestAlnumRun(value.slice(3)) >= 16 && hasDigit(value) && hasLetter(value.slice(3)),
322 ),
323 },
324 { kind: 'github', find: wholeMatches('github', /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g) },
325 { kind: 'github', find: wholeMatches('github', /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{82}(?![A-Za-z0-9_])/g) },
326 { kind: 'aws', find: wholeMatches('aws', /(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/g) },
327 {
328 // The access key's partner: a secret access key is 40 base64 characters
329 // and recognisable only by its label, so it is caught beside one.
330 kind: 'aws',
331 find: groupMatches(
332 'aws',
333 /secret[ _.-]?access[ _.-]?key[^\r\n]{0,20}?(?<![A-Za-z0-9/+])([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])/gid,
334 1,
335 value => hasLetter(value) && /[0-9/+]/.test(value) && !isHexOnly(value),
336 ),
337 },
338 { kind: 'google', find: wholeMatches('google', /(?<![A-Za-z0-9_-])AIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g) },
339 { kind: 'slack', find: wholeMatches('slack', /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/g, hasDigit) },
340 { kind: 'stripe', find: wholeMatches('stripe', /(?<![A-Za-z0-9_])[sr]k_live_[A-Za-z0-9]{24,}/g) },
341 {
342 kind: 'authorization',
343 find: groupMatches(
344 'authorization',
345 /authorization["'`]?[ \t]*[:=][ \t]*["'`]?[ \t]*(bearer|token|bot|basic)[ \t]+([A-Za-z0-9._~+/=-]{12,})/gid,
346 2,
347 (value, match) =>
348 match[1]!.toLowerCase() === 'basic' ? isBasicCredential(value) : value.length >= 20 && entropy(value) > 3.5,
349 ),
350 },
351 {
352 kind: 'cookie',
353 find: text =>
354 groupMatches('cookie', /(?<![A-Za-z0-9_-])(?:set-)?cookie["'`]?[ \t]*:[ \t]*([^\r\n"'`]+)/gid, 1, value =>
355 /(?:^|[;\s])[^=;\s]+=[^;\s]{20,}/.test(value),
356 )(text).map(span => ({ ...span, end: span.start + text.slice(span.start, span.end).trimEnd().length })),
357 },
358 {
359 // Cloudflare-style 40-character tokens have no prefix, so they count only
360 // within 20 characters after a label; a 40-hex git SHA never does.
361 kind: 'api-token',
362 find: groupMatches(
363 'api-token',
364 /(?:token|api[ _.-]?key|access[ _.-]?key|auth[ _.-]?key)[^\r\n]{0,20}?(?<![A-Za-z0-9_-])([A-Za-z0-9_-]{40})(?![A-Za-z0-9_-])/gid,
365 1,
366 value => hasDigit(value) && hasLetter(value) && !isHexOnly(value),
367 ),
368 },
369 { kind: 'password', find: passwords },
370]
371
372/** Every enabled detector's hits, overlaps merged into one span named by its widest member. */
373function findSpans(text: string, enabled: ReadonlySet<KindId>): Span[] {
374 const fences = findFences(text)
375 const hits = DETECTORS.filter(detector => enabled.has(detector.kind))
376 .flatMap(detector => detector.find(text, fences))
377 .filter(span => span.end > span.start)
378 .sort((a, b) => a.start - b.start || b.end - a.end)
379
380 const merged: Span[] = []
381 let current: { kind: KindId; start: number; end: number; width: number } | undefined
382 for (const hit of hits) {
383 const width = hit.end - hit.start
384 if (current && hit.start < current.end) {
385 current.end = Math.max(current.end, hit.end)
386 const isWider = width > current.width
387 const isTieWon = width === current.width && PRIORITY.get(hit.kind)! < PRIORITY.get(current.kind)!
388 if (isWider || isTieWon) {
389 current.kind = hit.kind
390 current.width = width
391 }
392 continue
393 }
394 if (current) merged.push({ kind: current.kind, start: current.start, end: current.end })
395 current = { kind: hit.kind, start: hit.start, end: hit.end, width }
396 }
397 if (current) merged.push({ kind: current.kind, start: current.start, end: current.end })
398 return merged
399}
400
401export type Finding = { readonly kind: KindId; readonly placeholder: string }
402
403export type Redaction = {
404 /** The text with each value replaced by `<REDACTED:kind#n>`. */
405 readonly redacted: string
406 /** One per distinct value, in order of first appearance; the same value twice shares a placeholder. */
407 readonly findings: readonly Finding[]
408 /** The distinct kinds found, in priority order. */
409 readonly kinds: readonly KindId[]
410}
411
412/** Scan-then-redact passes before giving up: a redaction can bring a label within reach of a later value. */
413const MAX_PASSES = 4
414
415/**
416 * Finds every enabled credential shape in `text` and replaces each value with
417 * a numbered placeholder, re-scanning the result until nothing matches.
418 *
419 * Throws (with no value in the message) when the text is not a string or the
420 * redaction does not settle; the hooks' `.catch` turns either into a hold.
421 */
422export function redact(text: string, enabled: ReadonlySet<KindId>): Redaction {
423 if (typeof text !== 'string') throw new TypeError('secret-guard: the prompt text is not a string')
424 const numbers = new Map<string, string>()
425 const counts = new Map<KindId, number>()
426 const findings: Finding[] = []
427 let current = text
428 for (let pass = 0; pass < MAX_PASSES; pass += 1) {
429 const spans = findSpans(current, enabled)
430 if (spans.length === 0) {
431 const kinds = KINDS.map(kind => kind.id).filter(id => findings.some(finding => finding.kind === id))
432 return { redacted: current, findings, kinds }
433 }
434 let rebuilt = ''
435 let cursor = 0
436 for (const span of spans) {
437 const key = `${span.kind}\u0000${current.slice(span.start, span.end)}`
438 let placeholder = numbers.get(key)
439 if (placeholder === undefined) {
440 const n = (counts.get(span.kind) ?? 0) + 1
441 counts.set(span.kind, n)
442 placeholder = `<REDACTED:${span.kind}#${n}>`
443 numbers.set(key, placeholder)
444 findings.push({ kind: span.kind, placeholder })
445 }
446 rebuilt += current.slice(cursor, span.start) + placeholder
447 cursor = span.end
448 }
449 current = rebuilt + current.slice(cursor)
450 }
451 throw new Error('secret-guard: the redaction did not settle')
452}
453
454/** Whether any enabled shape is still in `text` (the post-redaction check the tests make). */
455export function hasCredential(text: string, enabled: ReadonlySet<KindId>): boolean {
456 return findSpans(text, enabled).length > 0
457}
458
459/** "Shopify", "Shopify and GitHub", "Shopify, GitHub and AWS": labels only, never values. */
460export function kindsPhrase(kinds: readonly KindId[]): string {
461 const labels = kinds.map(id => KINDS.find(kind => kind.id === id)?.label ?? id)
462 if (labels.length <= 1) return labels[0] ?? 'credential'
463 return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
464}
465