SLOPSHOPPER

redact-secrets

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

newguardprompt
v0.1.0MITupdated 2026-10-09binbandit/bin-stack/mods/redact-secrets
A shopper browsing a rack in a slop shop
README

redact-secrets

Swaps secrets for placeholders like [REDACTED:github-token:3f9a1c2b] before Claude reads them, and before the session writes them to its transcript.

What it covers

  • Tool results. Every tool's result is scanned, including Read, Bash, Grep, MCP tools and subagents' tools. The scan applies to what the model reads and to the record the transcript stores.
  • Transcript rows. Every row is scanned before it is stored: prompts, replies, tool results, reminders and compaction summaries.
  • Prompts. A secret you paste is redacted before the prompt is queued.
  • @ 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.
  • OpenTelemetry. String attributes sent to your collector are scanned.

It detects:

  • Private keys (PEM, including truncated slices)
  • AWS, GitHub, GitLab, Anthropic, OpenAI, Stripe, Slack, Google, npm, Hugging Face and SendGrid tokens
  • JWTs and URL passwords
  • Bearer and Basic auth headers
  • Values assigned to secret-looking names (DB_PASSWORD=, apiKey: "...")

Once a secret has been seen, it is also redacted wherever it shows up later, for example in echo $DB_PASSWORD.

Writing secrets back

Within a session, each placeholder maps back to its real value:

  • Write, Edit and NotebookEdit put the real value back. Claude can rewrite .env or edit around a key without destroying it.
  • Shell commands that carry a placeholder are refused, so Claude can't echo one over a real file.
  • Placeholders from before a reload (/reload-plugins) are refused until Claude reads the file again.

What it cannot reach

  • The first prompt of a headless claude -p run. The engine queues it before any plugin hook runs, so its queue-operation entry keeps the raw text.
  • An injected attachment's raw payload, such as a "file changed on disk" snippet. The model reads the redacted rendering, but the transcript keeps the engine's record.
  • The screen, before a row's rewrite lands, and the --debug log.
  • Secrets in a shape it doesn't recognise. A bare dictionary-word password in YAML (password: correcthorse) isn't caught.
Source 3 files
hooks/register.ts 85 lines
1import 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}
85
hooks/redactor.ts 106 lines
1import { 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}
106
hooks/rules.ts 112 lines
1export 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