Redacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, and counts…

Four Claude Code mods that hold Claude to your project's rules while it works. Each mod is its own plugin under mods/, written in TypeScript, and the repository is a plugin marketplace named guardrail-mods.
| Mod | What it does | Event it hooks |
|---|---|---|
wcag-enforcer | After a Write, Edit or shell command changes a .tsx, .jsx, .html, .vue or .css file, runs automated WCAG checks (eslint-plugin-jsx-a11y, axe-core, and a WCAG 2.2 AA text contrast check) and hands Claude each failure with file, line and rule | tool.call (after Write, Edit and Bash run), classic.Stop |
design-guardrail | Before a Write or Edit to a UI file, blocks hardcoded hex/rgb/hsl colors and px spacing and font values, naming the design token to use; refuses shell commands that write UI files and checks what other commands changed | tool.call (before Write and Edit, around Bash) |
qa-gate | When Claude tries to finish a turn after changing code (by any tool, the shell included), runs the lint, type check, spell check and test scripts in package.json and keeps Claude working until they pass | tool.call, classic.Stop |
secret-scrubber | Redacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, with a running count in the status line | session.append (tool results), prompt.attachment, prompt.mention, tool.call |
Built and type-checked against the declarations of Claude Code 2.1.296, using only events and APIs that build declares. Mods need Claude Code 2.1.287 or later; qa-gate's package manager and scope pickers need 2.1.271 or later.
What wcag-enforcer is, and isn't. It runs automated WCAG checks on the code Claude writes. That is not a WCAG compliance audit: static checks like these catch only part of WCAG (often put at around a third of issues), and nothing here tests keyboard operation, focus order, screen reader output or how content reads in context. Keep testing those by hand and with assistive technology.
Mods are code that runs with your permissions. Read them first: claude plugin validate mods/<mod> lists every event a mod hooks and every call it makes.
In a Claude Code session in your terminal, add the marketplace once and install the mods you want:
/plugin marketplace add WasteOfADrumBum/one-small-mods-pack
/plugin install wcag-enforcer@guardrail-mods
/plugin install design-guardrail@guardrail-mods
/plugin install qa-gate@guardrail-mods
/plugin install secret-scrubber@guardrail-mods
Or install one mod in a single line, answering y to add the marketplace and then picking a scope:
/plugin install secret-scrubber --marketplace WasteOfADrumBum/one-small-mods-pack
From your shell instead: claude plugin marketplace add WasteOfADrumBum/one-small-mods-pack, then claude plugin install <mod>@guardrail-mods, then /reload-plugins in any open session.
To try a mod from a clone without installing it, start Claude Code with claude --plugin-dir ./mods/<mod> (repeat the flag for several).
| Mod | Needs |
|---|---|
wcag-enforcer | For components: ESLint with eslint-plugin-jsx-a11y (or eslint-plugin-vuejs-accessibility for .vue) turned on in your ESLint config. For HTML: npm i -D axe-core jsdom. The contrast check needs nothing. A check whose tool is missing is skipped, and the status line keeps saying so (♿ eslint not running) for as long as it is, so it never reads as a pass. |
design-guardrail | A tokens file, design-tokens.json by default. Without one it stands aside, and the status line says so (🎨 design-guardrail idle: no tokens file at design-tokens.json). |
qa-gate | A package.json with any of the scripts it looks for. The status line names the checks that have no script (✅ qa-gate: 2/2 passed · no spellcheck, test script). |
secret-scrubber | Nothing. |
Every mod has two layers of settings, and the second wins:
/config or when you install, stored under pluginConfigs in your settings: enabled (on/off), configFile (the path of layer 2), and each mod's own path options (tokensFile, packageJson, patternsFile, ...)..claude/guardrail-mods/<mod>.json by default, checked in with the project so the whole team gets the same rules. It takes every option plus the lists. Each mod's config.example.json shows every key.// .claude/guardrail-mods/design-guardrail.json
{ "tokensFile": "src/styles/tokens.css", "ignore": ["**/*.stories.tsx"] }
| Mod | Options (/config) | Extra keys in the project file |
|---|---|---|
wcag-enforcer | enabled, configFile, extensions, eslintCommand, blockStopUntilFixed | eslint, eslintRulePrefixes, axe, nodeCommand, contrast, watchBash, timeoutMs |
design-guardrail | enabled, configFile, tokensFile, extensions, ignore, allow | allowSvgColors, blockBashWrites, watchBash, maxReported |
qa-gate | enabled, configFile, packageJson, packageManager, maxAttempts, scope, timeoutSeconds | checks, scripts, codeExtensions, testArgs, watchBash |
secret-scrubber | enabled, configFile, patternsFile, showToast | disabledRules, patterns |
Claude writes Card.tsx with and 
note.css with .note { color: #aaaaaa; background: #ffffff; }. Each tool result comes back with a note Claude reads before its next step:
wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in src/Card.tsx. Fix every one before moving on:
- src/Card.tsx:3:5 [jsx-a11y/alt-text] img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.
wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in note.css. Fix every one before moving on:
- note.css:1 [wcag/contrast-minimum] text #aaaaaa on #ffffff has contrast 2.32:1; WCAG 2.2 AA (1.4.3) needs 4.5:1
The status line shows ♿ 2 WCAG failures in 2 files until they're fixed. If Claude tries to finish with failures still open, the mod sends it back with the list (twice at most, then it lets the turn end with a toast). In a test run, Claude changed the color to #767676 (4.54:1) on its own.
Files changed through the shell are checked too: the ones a command names as written (redirects, tee, sed -i, cp/mv targets, curl -o) and, in a git repository, any UI file whose change time moved while the command ran, so a code generator's output is covered. Set watchBash to false to skip that.
HTML files go through axe-core in jsdom (mods/wcag-enforcer/scripts/axe-check.mjs), reported as [axe/image-alt], [axe/html-has-lang] and so on, with line numbers. jsdom does no layout, so axe's own contrast rule is off and the mod's contrast check covers it: literal color pairs in CSS rules, style="" attributes and JSX style objects, with var(--x) resolved when the file defines --x, and 3:1 for large text.
With this design-tokens.json:
{ "color": { "brand": { "$value": "#3366ff" } }, "space": { "3": "12px" } }
Claude tries to write .card { color: #3366ff; padding: 13px; }. The Write is refused, and Claude reads:
design-guardrail: this Write to src/card.css hardcodes 2 values that should be design tokens (from design-tokens.json):
- line 1: `#3366ff` → use `color.brand` (`var(--color-brand)`).
- line 1: padding `13px` is not a token value → use the nearest token, `space.3` (`var(--space-3)`) = 12px.
Replace each with the token named and retry. A value that truly must stay can be kept with a `guardrail-ignore` comment on its line.
It retries with .card { color: var(--color-brand); padding: var(--space-3); }. The tokens file can be JSON (nested, W3C $value or Style Dictionary value leaves) or a CSS, SCSS or Less file of variables. It catches hex, rgb(), hsl(), px in spacing and font properties, React style numbers (marginTop: 12) and Tailwind arbitrary values (p-[10px], text-[#f00]). An Edit is judged only on the values it adds, so legacy values already in a file don't block unrelated edits.
What it leaves alone, each covered by a test: borders and other properties outside spacing and type (border: 1px solid ..., box-shadow, width: 24px), 0 and 0px, media query breakpoints (@media (min-width: 768px)), SVG attributes (width="24", stroke-width, and paint colors like fill="#111827" unless allowSvgColors is false), var() references and their fallbacks, token definitions themselves, and any line marked guardrail-ignore. Add values to allow (such as 1px) to let them through everywhere.
The shell can't sidestep it. A command that writes a UI file directly (echo ... > a.css, tee, sed -i, perl -pi, curl -o) is refused with a note to use Write or Edit. Any other command that changes a UI file (a generator script, say) runs, and in a git repository the values it added since the last commit come back to Claude as a note to fix with Edit. blockBashWrites and watchBash turn those off.
Claude edits src/a.ts and tries to finish. The gate runs npm run --silent lint, typecheck, spellcheck and test (whichever exist; npm's placeholder test is skipped) with CI=true, and when one fails Claude reads:
qa-gate: 1 check failed after your code changes. Fix the errors below, then finish:
### typecheck (`npm run --silent typecheck` exited 2)
src/a.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.
The status line tracks it: 🧪 qa-gate: running typecheck…, then ❌ qa-gate: typecheck failed or ✅ qa-gate: 3/3 passed. A turn that changed no code is never held, and code changed through the shell counts (what a command names as written, and in a git repository what changed while it ran). After maxAttempts holds in a row (3 by default) it lets the turn end with a toast. pnpm, yarn and bun are picked from the lockfile.
For bigger repositories:
timeoutSeconds (300 by default) stops a script that runs too long and tells Claude it timed out.scope: "changed" passes only the files Claude changed to the lint and spell check scripts (npm run --silent lint -- src/a.ts). Tests get them too once you set testArgs to your runner's flag, such as ["--findRelatedTests"] for Jest; otherwise the full suite runs. The type check always runs on the whole project, since it can't be judged file by file.Claude reads .env.local (with Read, cat, or an @.env.local mention in your prompt), then runs a script that prints a token. What Claude reads is:
PORT=[REDACTED:env-value]
DATABASE_URL=[REDACTED:env-value]
Using token [REDACTED:github-token] for api.github.com
The status line keeps a running count (🔒 3 secrets redacted) and a toast says what was caught. It covers private key blocks, JWTs, GitHub, Google (API keys, OAuth tokens, client secrets), AWS (key IDs and secret keys), Vercel, Todoist, Slack, Stripe, Anthropic and OpenAI keys, Bearer tokens, credentials in URLs, every value of a .env file Claude reads, and generic password=, api_key:, "secret": "..." pairs whose value looks like a secret (code like token = getToken() is left alone). Add your own with patterns.
Where it scrubs: every tool result before it's stored (in the main conversation and in every subagent's, so a subagent never reads the secret and its report back to Claude is scrubbed too), and every attachment as a request carries it, which covers files you @-mention, files changed outside Claude that it's shown, and settings hook output. Your terminal still shows the real output; only what Claude reads is scrubbed. If the scan itself fails, the output is withheld from Claude rather than passed through.
design-guardrail fails open: if the guard itself errors, the edit goes ahead.secret-scrubber fails closed: if scrubbing errors, Claude gets a placeholder instead of the output.wcag-enforcer and qa-gate never hold a turn forever (two and maxAttempts holds in a row).npm run types # copy your Claude Code build's type declarations into types/ (see below)
npm run typecheck # tsc against them
npm run validate # claude plugin validate on the marketplace and each mod
npm test # claude plugin test in each mod
The type declarations (claude-code.d.ts) are Anthropic's and are not redistributed here (Claude Code's license reserves all rights), so types/ is git-ignored. Claude Code writes them for your build beside a mod it loads from a folder it watches: start claude --plugin-dir mods/qa-gate once, then run npm run types, which copies them from mods/*/.claude-plugin/types/ into types/claude-code.d.ts. You can also pass a path: node scripts/link-types.mjs /path/to/claude-code.d.ts.
Each mod's tests live in mods/<mod>/tests/ and use claude-code/testing: they raise the events the mod hooks and stub every file, process and display call beneath it, covering pass and fail cases. claude plugin validate and claude plugin test need no types.
hooks/register.ts 161 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { asBool, asList, asString, parseConfigText, pick } from './config'
4import type { RawConfig } from './config'
5import { BUILTIN_RULES, scrub, userRules } from './patterns'
6import type { SecretRule } from './patterns'
7
8type Block = { type: string; [field: string]: unknown }
9
10type Settings = {
11 enabled: boolean
12 showToast: boolean
13 rules: SecretRule[]
14}
15
16// A .env file, but not the checked-in examples beside it.
17const ENV_FILE = /(^|[\\/])\.env(\.(?!example\b|sample\b|template\b|dist\b)[\w-]+)?$/
18const ENV_IN_COMMAND = /(^|[\s'"=\\/])\.env(\.(?!example\b|sample\b|template\b|dist\b)[\w-]+)?(?=$|[\s'";|&)])/
19
20/** Reads a JSON config file of the project; a missing or broken one is empty. */
21async function readConfigFile($: EngineInterface, path: string): Promise<RawConfig> {
22 if (path.trim() === '' || !(await $.fs.exists(path))) return {}
23 const parsed = parseConfigText(path, await $.fs.read(path))
24 if (typeof parsed === 'string') {
25 $.ui.log(parsed)
26 return {}
27 }
28 return parsed
29}
30
31async function load($: EngineInterface, options: PluginOptions): Promise<Settings> {
32 const configFile = asString(options.configFile) ?? '.claude/guardrail-mods/secret-scrubber.json'
33 const file = await readConfigFile($, configFile)
34 const log = (message: string) => $.ui.log(message)
35
36 const disabled = new Set(pick(file, options, 'disabledRules', [] as string[], asList))
37 const rules = BUILTIN_RULES.filter(rule => !disabled.has(rule.name))
38 rules.push(...userRules(file.patterns, log))
39
40 const patternsFile = pick(file, options, 'patternsFile', '', asString)
41 if (patternsFile !== '') {
42 const extra = await readConfigFile($, patternsFile)
43 rules.push(...userRules(extra.patterns, log))
44 }
45
46 return {
47 enabled: pick(file, options, 'enabled', true, asBool),
48 showToast: pick(file, options, 'showToast', true, asBool),
49 rules,
50 }
51}
52
53/** Scrubs a tool_result block's text, whether its content is a string or blocks. */
54function scrubBlock(block: Block, rules: readonly SecretRule[], isEnv: boolean) {
55 let count = 0
56 const kinds: string[] = []
57 const take = (text: string) => {
58 const done = scrub(text, rules, isEnv)
59 count += done.count
60 kinds.push(...done.kinds)
61 return done.text
62 }
63
64 if (block.type === 'text' && typeof block.text === 'string') {
65 return { block: { ...block, text: take(block.text) }, count, kinds }
66 }
67 if (block.type !== 'tool_result') return { block, count, kinds }
68
69 const { content } = block
70 if (typeof content === 'string') return { block: { ...block, content: take(content) }, count, kinds }
71 if (Array.isArray(content)) {
72 const next = content.map((inner: unknown) => {
73 const item = inner as Block
74 return item.type === 'text' && typeof item.text === 'string' ? { ...item, text: take(item.text) } : item
75 })
76 return { block: { ...block, content: next }, count, kinds }
77 }
78 return { block, count, kinds }
79}
80
81// Secrets redacted this session, shown in the status line.
82let total = 0
83
84function announce($: EngineInterface, settings: Settings, count: number, kinds: Iterable<string>, source: string) {
85 total += count
86 $.ui.status(`🔒 ${total} secret${total === 1 ? '' : 's'} redacted`)
87 if (settings.showToast) $.ui.toast(`secret-scrubber: redacted ${count} from ${source} (${[...kinds].join(', ')})`)
88}
89
90export const register: Register = (on, options) => {
91 // Tool calls whose output is the text of a .env file, by tool_use_id
92 const envCalls = new Set<string>()
93
94 on('tool.call', ($, e, next) => {
95 const isEnv =
96 (e.tool === 'Read' && ENV_FILE.test(e.file_path)) ||
97 (e.tool === 'Bash' && ENV_IN_COMMAND.test(e.command))
98 if (isEnv) envCalls.add(e.tool_use_id)
99 return next(e)
100 })
101
102 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
103 const settings = await load($, options)
104 if (!settings.enabled) return next(e)
105
106 let count = 0
107 const kinds = new Set<string>()
108 const content = e.message.content.map(raw => {
109 const block = raw as Block
110 const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
111 const done = scrubBlock(block, settings.rules, envCalls.has(id))
112 envCalls.delete(id)
113 count += done.count
114 for (const kind of done.kinds) kinds.add(kind)
115 return done.block
116 })
117 if (count === 0) return next(e)
118
119 const tool = e.origin.kind === 'tool' ? e.origin.tool : 'tool'
120 announce($, settings, count, kinds, `${tool} output${e.agentId === undefined ? '' : ' in a subagent'}`)
121 return next({ ...e, message: { ...e.message, content } })
122 }).catch(($, e, next) => {
123 // Fail closed: if the scan itself failed, Claude reads no output at all.
124 if (next.called) return next(e)
125 const content = e.message.content.map(raw => {
126 const block = raw as Block
127 return block.type === 'tool_result'
128 ? { ...block, content: '[secret-scrubber could not scan this output, so it was withheld]' }
129 : block
130 })
131 return next({ ...e, message: { ...e.message, content } })
132 })
133
134 // Files the person @-mentions reach Claude as attachments, not tool
135 // results, and are rendered again for each request: scrub them there.
136 const mentionedEnv = new Set<string>()
137 const counted = new Set<string>()
138
139 on('prompt.mention', ($, e, next) => {
140 if (ENV_FILE.test(e.path)) mentionedEnv.add(e.path)
141 return next(e)
142 })
143
144 on('prompt.attachment', async ($, e, next) => {
145 const settings = await load($, options)
146 if (!settings.enabled) return next(e)
147 const isEnv = [...mentionedEnv].some(path => e.text.includes(path))
148 const done = scrub(e.text, settings.rules, isEnv)
149 if (done.count === 0) return next(e)
150 // Count each attachment once, however many requests carry it.
151 if (!counted.has(e.text)) {
152 counted.add(e.text)
153 announce($, settings, done.count, done.kinds, e.type === 'file' ? 'a mentioned file' : `the ${e.type} attachment`)
154 }
155 return next({ ...e, text: done.text })
156 }).catch(($, e, next) =>
157 // Fail closed, as for tool output.
158 next.called ? next(e) : next({ ...e, text: '[secret-scrubber could not scan this attachment, so it was withheld]' }),
159 )
160}
161hooks/config.ts 53 lines1// Shared by every guardrail mod (each plugin carries its own copy, since a
2// plugin may only import its own files). Settings are layered:
3// 1. the mod's built-in defaults,
4// 2. the plugin's userConfig options (/config, or pluginConfigs in settings),
5// 3. the project's JSON config file, when it exists.
6// Reading the file needs `$`, which never crosses an import, so each
7// register.ts reads it and hands the text to `parseConfigText`.
8import type { PluginOptions } from 'claude-code'
9
10export type RawConfig = Record<string, unknown>
11
12const isRecord = (value: unknown): value is RawConfig =>
13 typeof value === 'object' && value !== null && !Array.isArray(value)
14
15/** Parses a config file's text: an object, or an error message to log. */
16export function parseConfigText(path: string, text: string): RawConfig | string {
17 try {
18 const parsed: unknown = JSON.parse(text)
19 return isRecord(parsed) ? parsed : `${path} is not a JSON object; using the defaults`
20 } catch (error) {
21 return `could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`
22 }
23}
24
25/** The first defined value among the file's, the option's and the default. */
26export function pick<T>(
27 file: RawConfig,
28 options: PluginOptions,
29 key: string,
30 fallback: T,
31 read: (value: unknown) => T | undefined,
32): T {
33 return read(file[key]) ?? read(options[key]) ?? fallback
34}
35
36export const asBool = (value: unknown): boolean | undefined =>
37 typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : undefined
38
39export const asString = (value: unknown): string | undefined =>
40 typeof value === 'string' ? value : undefined
41
42export const asNumber = (value: unknown): number | undefined =>
43 typeof value === 'number' && Number.isFinite(value) ? value : undefined
44
45/** A list from a JSON array, a `multiple` option, or a comma-separated string. */
46export const asList = (value: unknown): string[] | undefined => {
47 if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
48 if (typeof value === 'string' && value.trim() !== '') {
49 return value.split(',').map(item => item.trim()).filter(item => item !== '')
50 }
51 return undefined
52}
53hooks/patterns.ts 157 lines1// What counts as a secret, and how a text is scrubbed of them. Pure
2// functions, so the tests exercise them without the engine.
3
4export type SecretRule = {
5 /** Shown in the placeholder: `[REDACTED:<name>]`. */
6 name: string
7 pattern: RegExp
8 /**
9 * The capture group holding the secret itself, when the match also holds a
10 * label to keep (`password=` in `password=hunter2`); 0 (the default) is the
11 * whole match.
12 */
13 group?: number
14 /** Keeps a candidate value that is not a secret after all (code, a variable reference). */
15 accept?: (value: string) => boolean
16}
17
18// A generic key=value hit is a secret only when the value looks like one: a
19// literal with a digit or a long opaque run, never code (`getToken()`,
20// `process.env.TOKEN`, `${token}`) or a type (`token: string`).
21const looksLikeSecretValue = (value: string): boolean => {
22 if (/[()${}<>]/.test(value)) return false
23 if (/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+!?$/.test(value)) return false
24 if (/^(?:true|false|null|undefined|none|string|number|boolean|required|optional|changeme|redacted|xxx+|\*+)$/i.test(value)) return false
25 return /\d/.test(value) || value.length >= 20
26}
27
28// Names that mark the value beside them as a secret in key=value, key: value
29// and "key": "value" forms, including .env lines and JSON.
30const SECRET_NAME =
31 String.raw`[A-Za-z0-9_.-]*(?:secret|passw(?:or)?d|pwd|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?key|credential|session[_-]?key|signing[_-]?key)[A-Za-z0-9_.-]*`
32
33export const BUILTIN_RULES: readonly SecretRule[] = [
34 {
35 name: 'private-key',
36 pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g,
37 },
38 { name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
39 { name: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g },
40 { name: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
41 { name: 'google-oauth-token', pattern: /\bya29\.[0-9A-Za-z_-]{20,}/g },
42 { name: 'google-client-secret', pattern: /\bGOCSPX-[0-9A-Za-z_-]{20,}/g },
43 { name: 'aws-access-key-id', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[0-9A-Z]{16}\b/g },
44 {
45 name: 'aws-secret-access-key',
46 pattern: /(aws_?secret_?(?:access_?)?key["']?\s*[:=]\s*["']?)([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi,
47 group: 2,
48 },
49 // Vercel tokens carry no fixed prefix in older formats, so they are caught
50 // by their label (VERCEL_TOKEN=..., "vercelToken": "...") as well as the
51 // newer vc*_ prefixes.
52 { name: 'vercel-token', pattern: /\bvc[a-z]_[A-Za-z0-9]{24,}\b/g },
53 {
54 name: 'vercel-token',
55 pattern: /(vercel[_-]?(?:api[_-]?)?(?:token|key)["']?\s*[:=]\s*["']?)([A-Za-z0-9_-]{20,})/gi,
56 group: 2,
57 },
58 // Todoist API tokens are 40 hex characters with no prefix: caught by label.
59 {
60 name: 'todoist-token',
61 pattern: /(todoist[_-]?(?:api[_-]?)?(?:token|key)["']?\s*[:=]\s*["']?)([0-9a-f]{40})\b/gi,
62 group: 2,
63 },
64 { name: 'slack-token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
65 { name: 'stripe-key', pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
66 { name: 'anthropic-key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
67 { name: 'openai-key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
68 { name: 'bearer-token', pattern: /(\bBearer\s+)([A-Za-z0-9._~+/-]{20,}=*)/g, group: 2 },
69 { name: 'url-credentials', pattern: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]{3,})(?=@)/gi, group: 2 },
70 {
71 name: 'generic-secret',
72 pattern: new RegExp(
73 String.raw`(["']?${SECRET_NAME}["']?\s*[:=]\s*)(?:"([^"\s]{6,})"|'([^'\s]{6,})'|([^\s"',;}{)\]]{6,}))`,
74 'gi',
75 ),
76 group: -1,
77 accept: looksLikeSecretValue,
78 },
79]
80
81// A line of a .env file: every value is treated as a secret, whatever its name.
82const ENV_LINE = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=\s*)(.+?)\s*$/gm
83
84const placeholder = (name: string) => `[REDACTED:${name}]`
85
86const isPlaceholder = (text: string) => /^\[REDACTED:[a-z0-9-]+\]$/.test(text)
87
88export type ScrubResult = { text: string; count: number; kinds: string[] }
89
90/** Replaces every secret the rules find in `text`; `isEnvFile` also blanks each KEY=value line's value. */
91export function scrub(text: string, rules: readonly SecretRule[], isEnvFile = false): ScrubResult {
92 let count = 0
93 const kinds = new Set<string>()
94 let out = text
95
96 if (isEnvFile) {
97 out = out.replace(ENV_LINE, (whole, head: string, value: string) => {
98 if (value.trim() === '' || value.trim().startsWith('#') || isPlaceholder(value)) return whole
99 count += 1
100 kinds.add('env-value')
101 return head + placeholder('env-value')
102 })
103 }
104
105 for (const rule of rules) {
106 const pattern = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : rule.pattern.flags + 'g')
107 out = out.replace(pattern, (...args: unknown[]) => {
108 const match = args[0] as string
109 const groups = args.slice(1, -2) as (string | undefined)[]
110 if (match.includes('[REDACTED:')) return match
111 if (rule.accept !== undefined) {
112 const candidate = rule.group === -1 ? groups.slice(1, 4).find(g => g !== undefined) : rule.group ? groups[rule.group - 1] : match
113 if (candidate !== undefined && !rule.accept(candidate)) return match
114 }
115 count += 1
116 kinds.add(rule.name)
117 if (rule.group === undefined || rule.group === 0) return placeholder(rule.name)
118 if (rule.group === -1) {
119 // generic-secret: keep the label and any quotes, replace the value
120 const [label = '', doubleQuoted, singleQuoted] = groups
121 if (doubleQuoted !== undefined) return `${label}"${placeholder(rule.name)}"`
122 if (singleQuoted !== undefined) return `${label}'${placeholder(rule.name)}'`
123 return label + placeholder(rule.name)
124 }
125 const secret = groups[rule.group - 1] ?? ''
126 const label = groups.slice(0, rule.group - 1).join('')
127 return match.startsWith(label) && secret !== '' ? label + placeholder(rule.name) : placeholder(rule.name)
128 })
129 }
130
131 return { text: out, count, kinds: [...kinds] }
132}
133
134/** Builds rules from a user's `{ name, pattern, flags?, group? }` entries, skipping bad ones. */
135export function userRules(entries: unknown, onError: (message: string) => void): SecretRule[] {
136 if (!Array.isArray(entries)) return []
137 const rules: SecretRule[] = []
138 for (const entry of entries) {
139 if (typeof entry !== 'object' || entry === null) continue
140 const { name, pattern, flags, group } = entry as Record<string, unknown>
141 if (typeof name !== 'string' || typeof pattern !== 'string') {
142 onError('a custom pattern needs a string "name" and "pattern"')
143 continue
144 }
145 try {
146 rules.push({
147 name: name.toLowerCase().replace(/[^a-z0-9-]+/g, '-'),
148 pattern: new RegExp(pattern, typeof flags === 'string' ? flags : 'g'),
149 group: typeof group === 'number' ? group : undefined,
150 })
151 } catch (error) {
152 onError(`custom pattern "${name}" is not a valid regular expression: ${String(error)}`)
153 }
154 }
155 return rules
156}
157