Redacts API keys, tokens, JWTs, private keys and connection strings from every tool result before the model reads them, notes the redaction through context…

Replaces credential-shaped strings in every tool result before the model reads it, and refuses to echo a redacted placeholder back into a Bash command. One tool.call hook: it awaits next(e) (the "after" placement), deep-replaces every string in what came back and tells the model, through context, that a redaction happened so it is not confused by placeholders.
This is the shape the engine's author gives for a redactor:
on("tool.call", async ($, e, next) => recursiveStrReplace(await next(e), ...))
Seat it as low as possible (an org appends it in managed settings) so no plugin above ever sees the raw value on the way up.
If redaction itself fails (it throws or runs out of its time budget), the output is withheld, never passed on unredacted. The tool has already run by then; only what it printed is kept back.
patterns: string extra regex sources, comma-separated (global flag is added)
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "secret-redactor@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod security/secret-redactor
claude
It is written to .claude/skills/secret-redactor/, which Claude Code auto-loads as secret-redactor@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/secret-redactor. claude plugin validate .claude/skills/secret-redactor prints every event it hooks and every $ call it makes.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/secret-redactor.ts 132 lines1/**
2 * secret-redactor — Claude Mod
3 *
4 * Replaces credential-shaped strings in every tool result before the model
5 * reads it, and refuses to echo a redacted placeholder back into a Bash
6 * command. One `tool.call` hook: it awaits `next(e)` (the "after" placement),
7 * deep-replaces every string in what came back and tells the model, through
8 * `context`, that a redaction happened so it is not confused by placeholders.
9 *
10 * This is the shape the engine's author gives for a redactor:
11 * on("tool.call", async ($, e, next) => recursiveStrReplace(await next(e), ...))
12 * Seat it as low as possible (an org appends it in managed settings) so no
13 * plugin above ever sees the raw value on the way up.
14 *
15 * Needs Claude Code >= 2.1.287. Typed
16 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
17 *
18 * Options:
19 * patterns: string extra regex sources, comma-separated (global flag is added)
20 */
21import type { Register } from 'claude-code'
22
23/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
24function strings(value: unknown): string[] | undefined {
25 const list = Array.isArray(value)
26 ? value.filter((v): v is string => typeof v === 'string')
27 : typeof value === 'string'
28 ? value.split(',').map((s) => s.trim()).filter(Boolean)
29 : []
30 return list.length > 0 ? list : undefined
31}
32
33interface Pattern { name: string; re: RegExp }
34
35const DEFAULT_PATTERNS: readonly Pattern[] = [
36 { name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/g },
37 { name: 'anthropic-api-key', re: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{20,}\b/g },
38 { name: 'openai-api-key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}\b/g },
39 { name: 'github-token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g },
40 { name: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
41 { name: 'stripe-key', re: /\b[sr]k_(live|test)_[0-9A-Za-z]{24,}\b/g },
42 { name: 'slack-token', re: /\bxox[abpr]-[0-9A-Za-z-]{10,}\b/g },
43 { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
44 { name: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
45 { name: 'connection-string', re: /\b(postgres(ql)?|mysql|mongodb(\+srv)?|redis):\/\/[^:\s]+:[^@\s]+@/gi },
46]
47
48function redactString(input: string, patterns: readonly Pattern[], hits: Set<string>): string {
49 let out = input
50 for (const { name, re } of patterns) {
51 out = out.replace(re, () => {
52 hits.add(name)
53 return `[REDACTED:${name}]`
54 })
55 }
56 return out
57}
58
59/** Walk any JSON-ish value and redact every string inside it. */
60function redactDeep<T>(value: T, patterns: readonly Pattern[], hits: Set<string>): T {
61 if (typeof value === 'string') return redactString(value, patterns, hits) as T
62 if (Array.isArray(value)) return value.map((v) => redactDeep(v, patterns, hits)) as T
63 if (value && typeof value === 'object') {
64 const copy: Record<string, unknown> = {}
65 for (const [k, v] of Object.entries(value)) copy[k] = redactDeep(v, patterns, hits)
66 return copy as T
67 }
68 return value
69}
70
71export const register: Register = (on, options) => {
72 // a custom pattern that does not compile is skipped, never a reason to lose the built-in ones
73 const custom: Pattern[] = []
74 for (const p of strings(options.patterns) ?? []) {
75 try {
76 custom.push({ name: 'custom', re: new RegExp(p, 'g') })
77 } catch {
78 // reported once the hook runs, where $.ui.log exists
79 }
80 }
81 const patterns: readonly Pattern[] = [...DEFAULT_PATTERNS, ...custom]
82
83 on('tool.call', async ($, e, next) => {
84 // 1. Never let a redacted placeholder travel back into a shell command.
85 if (e.tool === 'Bash' && e.command.includes('[REDACTED:')) {
86 return {
87 deny:
88 'The command contains a redacted secret placeholder. ' +
89 'Read the value from an environment variable instead of pasting it.',
90 }
91 }
92
93 // 2. Redact the result (or a denial's reason) before it enters the transcript.
94 const outcome = await next(e)
95 const hits = new Set<string>()
96 const cleaned = redactDeep(outcome, patterns, hits)
97 if (hits.size === 0) return outcome
98 if (cleaned.deny !== undefined) {
99 $.ui.log(`[secret-redactor] redacted ${[...hits].join(', ')} from a denial of ${e.tool}`)
100 return cleaned
101 }
102
103 const kinds = [...hits].join(', ')
104 $.ui.log(`[secret-redactor] redacted ${kinds} from ${e.tool} output`)
105 return {
106 ...cleaned,
107 context: [
108 ...(cleaned.context ?? []),
109 `secret-redactor replaced ${hits.size} kind(s) of secret (${kinds}) with [REDACTED:*] placeholders before you read this result. Never paste a placeholder into a command.`,
110 ],
111 }
112 }).catch(async ($, e, next) => {
113 // Fail closed: nothing reaches the transcript that was not scanned.
114 if (!next.called) {
115 $.ui.log(`[secret-redactor] check failed (${next.error.kind}); denied ${e.tool}`)
116 return { deny: `secret-redactor could not check this ${e.tool} call (${next.error.kind}), so it was not run.` }
117 }
118 try {
119 await next(e) // replayed: what the tool settled to, nothing runs again
120 } catch {
121 // The call itself failed; its error text is not passed on, since it was never scanned.
122 $.ui.log(`[secret-redactor] ${e.tool} failed; its error was withheld unscanned`)
123 return { deny: `The ${e.tool} call failed, and secret-redactor withheld its error unscanned.` }
124 }
125 // The tool ran and its output could not be scanned: its effects stand, only the output is kept back.
126 $.ui.log(`[secret-redactor] redaction failed (${next.error.kind}); withheld the ${e.tool} output`)
127 return {
128 deny: `secret-redactor could not scan the ${e.tool} output for secrets (${next.error.kind}), so it was withheld. The tool did run. Ask the user to check the output themselves.`,
129 }
130 })
131}
132