Redacts secrets from what Claude reads and from what the session logs

Swaps secrets for placeholders like [REDACTED:github-token:3f9a1c2b] before Claude reads them, and before the session writes them to its transcript.
@ mentions. A mentioned file that holds a secret is not attached. Claude is told to open it with Read instead, so the redacted copy is the only one in the log.It detects:
DB_PASSWORD=, apiKey: "...")Once a secret has been seen, it is also redacted wherever it shows up later, for example in echo $DB_PASSWORD.
Within a session, each placeholder maps back to its real value:
.env or edit around a key without destroying it./reload-plugins) are refused until Claude reads the file again.claude -p run. The engine queues it before any plugin hook runs, so its queue-operation entry keeps the raw text.--debug log.password: correcthorse) isn't caught.hooks/register.ts 85 lines1import type { Args, Register, ToolCallResult } from 'claude-code'
2import { isKnownToken, mapStrings, redact, restore, stringsIn, tokensIn } from './redactor'
3
4type ContentBlock = Args<'session.append'>['message']['content'][number]
5
6const PLUGIN = 'redact-secrets'
7const FS_READ_LIMIT_BYTES = 4 * 1024 * 1024
8const FILE_WRITING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
9const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
10
11const REDACTION_NOTICE =
12 'Secrets here are replaced with [REDACTED:<kind>:<id>] placeholders. Write and Edit put the real secret back wherever you pass a placeholder, so keep placeholders as they are when you edit or rewrite a file. Shell commands cannot carry them.'
13
14function redactBlock(block: ContentBlock): ContentBlock {
15 if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: redact(block.text) }
16 if (block.type !== 'tool_result') return block
17 if (typeof block.content === 'string') return { ...block, content: redact(block.content) }
18 if (Array.isArray(block.content)) return { ...block, content: block.content.map(redactBlock) }
19 return block
20}
21
22function withheldBlock(block: ContentBlock): ContentBlock {
23 const notice = `${PLUGIN} could not scan this for secrets, so it was withheld.`
24 if (block.type === 'text') return { ...block, text: notice }
25 if (block.type === 'tool_result') return { ...block, content: notice }
26 return block
27}
28
29function redactToolResult(ran: ToolCallResult): ToolCallResult {
30 if (ran.deny !== undefined) return ran
31 if (ran.isError) {
32 const error = ran.text ?? String(ran.result ?? '')
33 const redacted = redact(error)
34 return redacted === error ? ran : { deny: redacted }
35 }
36 const result = mapStrings(ran.result, redact)
37 if (result === ran.result) return ran
38 return { result, context: [...(ran.context ?? []), REDACTION_NOTICE] }
39}
40
41const unseenSecretNotice = (tokens: string[]) =>
42 `${tokens.join(', ')} stands in for a secret this session no longer holds. Read the file it came from again, then retry with the new placeholder.`
43
44const shellNotice = (tokens: string[]) =>
45 `${tokens.join(', ')} stands in for a secret you cannot see, so a shell command cannot carry it. Use Write or Edit, which put the real secret back.`
46
47const mentionNotice = (mention: string) =>
48 `@${mention} holds secrets, so it was not attached. Read it with the Read tool, which redacts them.`
49
50export const register: Register = on => {
51 on('tool.call', async ($, e, next) => {
52 const tokens = stringsIn(e).flatMap(tokensIn)
53 if (tokens.length > 0 && SHELL_TOOLS.has(e.tool)) return { deny: shellNotice(tokens) }
54 if (!FILE_WRITING_TOOLS.has(e.tool)) return redactToolResult(await next(e))
55 const unseen = tokens.filter(token => !isKnownToken(token))
56 if (unseen.length > 0) return { deny: unseenSecretNotice(unseen) }
57 return redactToolResult(await next(mapStrings(e, restore)))
58 }).catch(() => ({ deny: `${PLUGIN} could not scan this call for secrets, so it was withheld.` }))
59
60 on('session.append', ($, e, next) =>
61 next({ ...e, message: { ...e.message, content: e.message.content.map(redactBlock) } }),
62 ).catch(($, e, next) =>
63 next.called ? next(e) : next({ ...e, message: { ...e.message, content: e.message.content.map(withheldBlock) } }),
64 )
65
66 on('prompt.submit', ($, e, next) => {
67 const text = redact(e.text)
68 const context = (e.context ?? []).map(redact)
69 const isRedacted = text !== e.text || context.some((entry, index) => entry !== e.context?.[index])
70 return next({ ...e, text, ...(isRedacted ? { context: [...context, REDACTION_NOTICE] } : {}) })
71 }).catch(($, e, next) => (next.called ? next(e) : { drop: `${PLUGIN} could not scan this prompt for secrets.` }))
72
73 on('prompt.mention', async ($, e, next) => {
74 const stat = await $.fs.stat(e.path).catch(() => undefined)
75 if (stat === undefined || stat.kind !== 'file') return next(e)
76 const text = stat.size > FS_READ_LIMIT_BYTES ? undefined : await $.fs.read(e.path)
77 if (text !== undefined && redact(text) === text) return next(e)
78 return { type: null, context: [mentionNotice(e.mention)] }
79 }).catch(($, e, next) => (next.called ? next(e) : { type: null, context: [mentionNotice(e.mention)] }))
80
81 on('telemetry.log', { to: 'collector' }, ($, e, next) =>
82 e.to === 'collector' ? next({ ...e, attributes: mapStrings(e.attributes, redact) }) : next(e),
83 ).catch(($, e, next) => (next.called ? next(e) : { deny: `${PLUGIN} could not scan this record for secrets.` }))
84}
85hooks/redactor.ts 106 lines1import { SECRET_RULES } from './rules'
2
3type Span = { start: number; end: number; kind: string }
4
5const TOKEN = /\[REDACTED:[a-z0-9-]+:[0-9a-f]{8}\]/g
6const TOKEN_OR_PLAIN = /(\[REDACTED:[a-z0-9-]+:[0-9a-f]{8}\])/
7const LINE_BREAK = /(\r?\n)/
8const MIN_KNOWN_SECRET_LENGTH = 8
9const CHARACTER_CLASSES = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/]
10
11const tokenBySecret = new Map<string, string>()
12const secretByToken = new Map<string, string>()
13
14function tokenFor(secret: string, kind: string): string {
15 const known = tokenBySecret.get(secret)
16 if (known !== undefined) return known
17 const token = `[REDACTED:${kind}:${crypto.randomUUID().slice(0, 8)}]`
18 tokenBySecret.set(secret, token)
19 secretByToken.set(token, secret)
20 return token
21}
22
23function ruleSpans(text: string): Span[] {
24 return SECRET_RULES.flatMap(rule =>
25 [...text.matchAll(rule.pattern)].flatMap(match => {
26 const [start, end] = match.indices?.groups?.secret ?? [match.index, match.index + match[0].length]
27 const candidate = text.slice(start, end)
28 if (candidate.trim() === '' || rule.isSecret?.(candidate) === false) return []
29 return [{ start, end, kind: rule.kind }]
30 }),
31 )
32}
33
34const isDistinctive = (secret: string) =>
35 secret.length >= MIN_KNOWN_SECRET_LENGTH && CHARACTER_CLASSES.filter(characters => characters.test(secret)).length >= 2
36
37function knownSecretSpans(text: string): Span[] {
38 return [...tokenBySecret].flatMap(([secret, token]) => {
39 if (!isDistinctive(secret)) return []
40 const kind = token.split(':')[1] ?? 'secret'
41 const spans: Span[] = []
42 for (let start = text.indexOf(secret); start !== -1; start = text.indexOf(secret, start + secret.length)) {
43 spans.push({ start, end: start + secret.length, kind })
44 }
45 return spans
46 })
47}
48
49function withoutOverlaps(spans: Span[]): Span[] {
50 const byStartThenLongest = spans.toSorted((a, b) => a.start - b.start || b.end - a.end)
51 const kept: Span[] = []
52 for (const span of byStartThenLongest) {
53 const last = kept.at(-1)
54 if (last === undefined || span.start >= last.end) kept.push(span)
55 }
56 return kept
57}
58
59const tokenizeLines = (secret: string, kind: string) =>
60 secret
61 .split(LINE_BREAK)
62 .map(part => (part === '' || LINE_BREAK.test(part) ? part : tokenFor(part, kind)))
63 .join('')
64
65function redactPlain(text: string): string {
66 const spans = withoutOverlaps([...ruleSpans(text), ...knownSecretSpans(text)])
67 if (spans.length === 0) return text
68 let redacted = ''
69 let cursor = 0
70 for (const span of spans) {
71 redacted += text.slice(cursor, span.start) + tokenizeLines(text.slice(span.start, span.end), span.kind)
72 cursor = span.end
73 }
74 return redacted + text.slice(cursor)
75}
76
77export const redact = (text: string) =>
78 text
79 .split(TOKEN_OR_PLAIN)
80 .map((part, index) => (index % 2 === 1 ? part : redactPlain(part)))
81 .join('')
82
83export const restore = (text: string) => text.replace(TOKEN, token => secretByToken.get(token) ?? token)
84
85export const tokensIn = (text: string) => text.match(TOKEN) ?? []
86
87export const isKnownToken = (token: string) => secretByToken.has(token)
88
89export function mapStrings<T>(value: T, map: (text: string) => string): T {
90 if (typeof value === 'string') return map(value) as T
91 if (Array.isArray(value)) {
92 const mapped = value.map(item => mapStrings(item, map))
93 return mapped.some((item, index) => item !== value[index]) ? (mapped as T) : value
94 }
95 if (typeof value !== 'object' || value === null) return value
96 const entries = Object.entries(value)
97 const mapped = entries.map(([key, item]) => [key, mapStrings(item, map)] as const)
98 return mapped.some(([, item], index) => item !== entries[index]?.[1]) ? (Object.fromEntries(mapped) as T) : value
99}
100
101export function stringsIn(value: unknown): string[] {
102 if (typeof value === 'string') return [value]
103 if (typeof value !== 'object' || value === null) return []
104 return Object.values(value).flatMap(stringsIn)
105}
106hooks/rules.ts 112 lines1export type SecretRule = {
2 kind: string
3 pattern: RegExp
4 isSecret?: (candidate: string) => boolean
5}
6
7const capitalized = (segment: string) => segment.charAt(0).toUpperCase() + segment.slice(1)
8
9function nameSegment(...segments: string[]): string {
10 const [head = '', ...tail] = segments
11 const lower = `(?<![A-Za-z0-9])${segments.join('[_-]?')}(?![a-z])`
12 const upper = `(?<![A-Za-z0-9])${segments.map(segment => segment.toUpperCase()).join('[_-]?')}(?![A-Z])`
13 const camel = `${segments.map(capitalized).join('')}(?![a-z])`
14 const headless = `(?<![A-Za-z0-9])${head}${tail.map(capitalized).join('')}(?![a-z])`
15 return `(?:${[lower, upper, camel, ...(tail.length > 0 ? [headless] : [])].join('|')})`
16}
17
18const anyCase = (word: string) => `${word}|${capitalized(word)}|${word.toUpperCase()}`
19
20const SECRET_WORD = [
21 nameSegment('password'),
22 nameSegment('passwd'),
23 nameSegment('passphrase'),
24 nameSegment('secret'),
25 nameSegment('token'),
26 nameSegment('credentials'),
27 nameSegment('credential'),
28 ...['api', 'access', 'private', 'signing', 'encryption', 'master', 'auth'].map(prefix => nameSegment(prefix, 'key')),
29].join('|')
30
31const NOT_A_SECRET_NAME_SUFFIXES = ['url', 'uri', 'path', 'file', 'dir', 'endpoint', 'type', 'name', 'header', 'field']
32 .concat(['length', 'size', 'count', 'ttl', 'expiry', 'prefix', 'env', 'id'])
33 .map(anyCase)
34 .join('|')
35const SECRET_NAME = String.raw`[\w.-]{0,40}(?:${SECRET_WORD})[\w.-]{0,40}(?<!${NOT_A_SECRET_NAME_SUFFIXES})`
36
37const PEM_LINE = String.raw`(?:[A-Za-z0-9+/=]{1,128}|[A-Za-z-]{1,32}: [^\r\n\\]{0,128})`
38const PEM_BREAK = String.raw`(?:\r?\n|\\n)`
39const PEM_END = String.raw`-----END[A-Z0-9 ]{0,40}PRIVATE KEY`
40
41const PLACEHOLDER = /^(?:\$\{.*\}|\$\(.*\)|\$\w+|%\w+%|\{\{.*\}\}|<.*>|[x*•.-]+)$|your|example|placeholder|changeme|dummy|fake|sample|redacted|x{4,}|\.\.\.|…/i
42const NOT_A_VALUE = /^(?:[a-z][a-z0-9+.-]*:\/\/|[/~.:=]|[\^~<>=v]*\d+\.\d+)/i
43const ENV_VAR_REFERENCE = /(?:^|[/.:])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/
44const CODE_EXPRESSION = /[()[\]{}<>&]|;$|^!|^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+$/
45const BARE_WORD = /^[A-Za-z_$]+$/
46
47const isLiteralSecret = (candidate: string) =>
48 !PLACEHOLDER.test(candidate) &&
49 !NOT_A_VALUE.test(candidate) &&
50 !ENV_VAR_REFERENCE.test(candidate) &&
51 !/^\d+$/.test(candidate) &&
52 (/[\d\W_]/.test(candidate) || candidate.length >= 12)
53
54const isEnvValueSecret = (candidate: string) => isLiteralSecret(candidate) && !CODE_EXPRESSION.test(candidate)
55
56const isUnquotedSecret = (candidate: string) => isEnvValueSecret(candidate) && !BARE_WORD.test(candidate)
57
58export const SECRET_RULES: readonly SecretRule[] = [
59 {
60 kind: 'private-key',
61 pattern: new RegExp(
62 String.raw`-----BEGIN[A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----${PEM_BREAK}(?<secret>(?:${PEM_LINE})?(?:${PEM_BREAK}(?:${PEM_LINE})?)*?)(?=${PEM_BREAK}${PEM_END}|${PEM_BREAK}?$)`,
63 'dg',
64 ),
65 },
66 {
67 kind: 'private-key',
68 pattern: new RegExp(String.raw`^(?<secret>(?:[A-Za-z0-9+/=]{1,128}${PEM_BREAK})+)${PEM_END}`, 'dg'),
69 },
70 { kind: 'aws-access-key', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
71 { kind: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_\w{22,255})\b/g },
72 { kind: 'gitlab-token', pattern: /\bglpat-[\w-]{20,}/g },
73 { kind: 'anthropic-key', pattern: /\bsk-ant-[\w-]{20,}/g },
74 { kind: 'openai-key', pattern: /\bsk-(?:(?:proj|svcacct|admin)-[\w-]{20,}|[A-Za-z0-9]{32,}\b)/g },
75 { kind: 'stripe-key', pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
76 { kind: 'slack-token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
77 { kind: 'slack-webhook', pattern: /\bhttps:\/\/hooks\.slack\.com\/(?:services|workflows|triggers)\/[\w/+-]{20,}/g },
78 { kind: 'google-api-key', pattern: /\bAIza[\w-]{35}(?![\w-])/g },
79 { kind: 'npm-token', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
80 { kind: 'huggingface-token', pattern: /\bhf_[A-Za-z0-9]{34,}\b/g },
81 { kind: 'sendgrid-key', pattern: /\bSG\.[\w-]{22}\.[\w-]{43}\b/g },
82 { kind: 'jwt', pattern: /\beyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]{8,}/g },
83 {
84 kind: 'url-password',
85 pattern: /\b[a-z][a-z0-9+.-]{0,20}:\/\/[^\s:/@]{1,256}:(?<secret>[^\s:/@]{1,256})@/dgi,
86 isSecret: isLiteralSecret,
87 },
88 {
89 kind: 'auth-header',
90 pattern: /\b(?:[Bb]earer|Basic)\s+(?<secret>[\w.~+/-]{16,}=*)/dg,
91 isSecret: candidate => !PLACEHOLDER.test(candidate),
92 },
93 {
94 kind: 'credential',
95 pattern: new RegExp(String.raw`^[ \t]*(?:export[ \t]+)?${SECRET_NAME}=(?<secret>[^\s"'\x60#]{6,})`, 'dgm'),
96 isSecret: isEnvValueSecret,
97 },
98 {
99 kind: 'credential',
100 pattern: new RegExp(
101 String.raw`(?<![\w.-])["']?${SECRET_NAME}["']?\s*(?::=|=>|=|:)\s*(?<quote>["'\x60])(?<secret>[^\s"'\x60\\]{6,})\k<quote>`,
102 'dg',
103 ),
104 isSecret: isLiteralSecret,
105 },
106 {
107 kind: 'credential',
108 pattern: new RegExp(String.raw`(?<![\w.-])${SECRET_NAME}[ \t]*[=:][ \t]*(?<secret>[^\s"'\x60,;)}\]]{6,})`, 'dg'),
109 isSecret: isUnquotedSecret,
110 },
111]
112