SLOPSHOPPER

secret-redactor

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

newguard
A shopper browsing a rack in a slop shop
README

secret-redactor

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.

Options

  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": { } } } }

Install

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

Source 1 files
hooks/secret-redactor.ts 132 lines
1/**
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