SLOPSHOPPER

secret-guard

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

newtoastprompttimer
v0.1.0MITupdated 2026-10-12Joseph-Ayoub/claude-mods/secret-guard
A shopper browsing a rack in a slop shop
README

secret-guard

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:

  • Redact and reference: the value is swapped for a placeholder such as <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.
  • Send anyway: the prompt goes as typed, and a toast reminds you the value is now in the session transcript.
  • Cancel: the prompt is not sent, and your draft comes back in the prompt box with the value already redacted.

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.

Hooks it registers

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.

RegistrationMatcherWhat it does
Your own promptsorigin.kind is composer, bridge or sdk; text matches the candidate prefilterScans, asks, then redacts, sends or holds as above
Everything elseorigin.kind is any other kind, including kinds a later Claude Code build adds; text matches the candidate prefilterScans 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.

What it detects

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
shopifyshpat_, shpss_, shpca_, shppa_ + 32 hex
anthropicsk-ant- + 20 or more of [A-Za-z0-9_-]
openaisk-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)
githubghp_, gho_, ghu_, ghs_, ghr_ + 36; github_pat_ + 82
awsAKIA/ASIA + 16 [A-Z0-9]; a 40-character secret access key within 20 characters after a secret_access_key label
googleAIza + 35
slackxoxa-, xoxb-, xoxp-, xoxr-, xoxs- + 10 or more, with a digit
stripesk_live_ or rk_live_ + 24 or more
authorizationAuthorization: Bearer (or token, Bot) + 20 or more characters with Shannon entropy above 3.5; Authorization: Basic + a value that decodes to user:password
cookiea Cookie: or Set-Cookie: line holding a name=value pair whose value is 20 or more characters; the whole header value is redacted
api-tokena 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
passwordpassword/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.

Options

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.
KindFieldsDefault hint
private keyscanPrivateKey, hintPrivateKeythe key file on disk (for example ~/.ssh/id_ed25519), referenced by path
ShopifyscanShopify, hintShopifywrangler secret SHOPIFY_ADMIN_TOKEN
AnthropicscanAnthropic, hintAnthropicthe ANTHROPIC_API_KEY environment variable
OpenAI-stylescanOpenai, hintOpenaithe OPENAI_API_KEY environment variable
GitHubscanGithub, hintGithubgh auth token
AWSscanAws, hintAwsthe AWS profile in ~/.aws/credentials (aws configure)
Google APIscanGoogle, hintGooglethe GOOGLE_API_KEY environment variable
SlackscanSlack, hintSlackthe SLACK_BOT_TOKEN environment variable
StripescanStripe, hintStripewrangler secret STRIPE_SECRET_KEY
Authorization headerscanAuthorization, hintAuthorizationthe API_TOKEN environment variable
session cookiescanCookie, hintCookiea fresh login in the browser (cookies are never pasted)
API tokenscanApiToken, hintApiTokenwrangler secret CLOUDFLARE_API_TOKEN
passwordscanPassword, hintPasswordop read "op://Private/<item>/password"

How to load it

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.

Known limits

  • Prompt history. The composer's up-arrow history may keep the unredacted text. No hook reaches it.
  • Only the prompt's text is scanned. Pasted images and documents, files pulled in with @, 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.
  • Send anyway means sent. The value lands in the transcript file and in any UserPromptSubmit settings hooks; the toast says so.
  • Mid-turn prompts. A prompt typed while a turn runs raises the dialog over that turn.
  • Desktop app. If the desktop surface cannot show the dialog, the prompt is sent redacted (never dropped). After Cancel there is no prompt box to refill there, so the redacted draft goes to the clipboard, which replaces what was on it.
  • Heuristics. Generic shapes are deliberately narrow: an unlabelled token with no vendor prefix, a labelled token that is not exactly 40 characters, a password on a line of 200 characters or more (minified JSON), or a password written in prose without : 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.
  • Retries the test kit cannot drive. The refill's retry (a dialog still closing) and clipboard fallback (no prompt box) depend on refusal causes only the engine sets; their decision logic is unit-tested, the wiring is not.
Source 2 files
hooks/register.tsx 232 lines
1/**
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}
232
hooks/scan.ts 465 lines
1/**
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